Skip to content

Commit 0f82a83

Browse files
[#300]: Add TypeDirectedDocumentLoader for type-based dispatch
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d2cd732 commit 0f82a83

7 files changed

Lines changed: 241 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
- `pyld.FileDocumentLoader`: a document loader for local `file:` URLs with optional root confinement.
77
- `pyld.SchemeDirectedDocumentLoader`: a document loader that dispatches URL
88
strings to per-scheme loaders.
9+
- `pyld.TypeDirectedDocumentLoader`: a document loader that dispatches by Python
10+
input type (e.g. `pathlib.Path` vs `str`).
911

1012
## 3.2.0 - 2026-08-17
1113

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import json
2+
from pathlib import Path
3+
4+
from pyld import DocumentLoader, TypeDirectedDocumentLoader, jsonld
5+
6+
person = Path(__file__).resolve().parent.parent / 'data' / 'person.jsonld'
7+
8+
9+
class PathDocumentLoader(DocumentLoader):
10+
def __call__(self, url, options=None):
11+
return {
12+
'contentType': 'application/ld+json',
13+
'contextUrl': None,
14+
'documentUrl': url.resolve().as_uri(),
15+
'document': url.read_text(encoding='utf-8'),
16+
}
17+
18+
19+
class StrDocumentLoader(DocumentLoader):
20+
def __call__(self, url, options=None):
21+
raise AssertionError(f'unexpected str load: {url}')
22+
23+
24+
loader = TypeDirectedDocumentLoader(
25+
{
26+
Path: PathDocumentLoader(),
27+
str: StrDocumentLoader(),
28+
}
29+
)
30+
remote = jsonld.load_document(person, options={'documentLoader': loader})
31+
result = jsonld.expand(
32+
remote['document'],
33+
options={
34+
'documentLoader': loader,
35+
'base': remote['documentUrl'],
36+
},
37+
)
38+
print(json.dumps(result, indent=2))

docs/reference/document-loaders/index.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ class-based loaders for common cases and supports custom subclasses of
4444

4545
Delegate to another Document Loader based on the scheme of the URL, for instance, `file:` vs `https://`.
4646

47+
- [:material-shape-outline:{ .lg .middle } `TypeDirectedDocumentLoader`](type-directed.md)
48+
49+
---
50+
51+
Delegate by Python input type, for instance `pathlib.Path` vs URL `str`.
52+
4753
- [:material-code-braces:{ .lg .middle } __Custom Document Loaders__](custom.md)
4854

4955
---
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
hide: [toc]
3+
---
4+
# :material-shape-outline: `TypeDirectedDocumentLoader`
5+
6+
::: pyld.TypeDirectedDocumentLoader
7+
options:
8+
show_root_heading: false
9+
show_bases: false
10+
heading_level: 3
11+
members: false
12+
13+
Dispatch by Python input type — here a `pathlib.Path` vs a URL `str`:
14+
15+
=== "Example"
16+
17+
{{ example('document_loaders/type_directed.py', output_syntax='json', indent=4) }}
18+
19+
=== "person.jsonld"
20+
21+
{{ example_data('data/person.jsonld', indent=4) }}

lib/pyld/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
from .documentloader.requests import RequestsDocumentLoader
1010
from .documentloader.requests_sqlite_cache import SqliteCacheRequestsDocumentLoader
1111
from .documentloader.scheme_directed import SchemeDirectedDocumentLoader
12+
from .documentloader.type_directed import TypeDirectedDocumentLoader
1213

1314
__all__ = [
1415
'AioHttpDocumentLoader',
1516
'BUNDLED_CONTEXTS',
1617
'SchemeDirectedDocumentLoader',
18+
'TypeDirectedDocumentLoader',
1719
'ContextResolver',
1820
'DocumentLoader',
1921
'FileDocumentLoader',
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
Type-dispatching JSON-LD document loader.
3+
4+
.. module:: jsonld.documentloader.type_directed
5+
:synopsis: TypeDirectedDocumentLoader for type-based document loading
6+
"""
7+
8+
from collections.abc import Mapping
9+
from dataclasses import dataclass
10+
from typing import Any
11+
12+
from pyld.documentloader.base import DocumentLoader, RemoteDocument
13+
from pyld.jsonld import JsonLdError
14+
15+
16+
@dataclass
17+
class TypeDirectedDocumentLoader(DocumentLoader):
18+
"""Document loader that dispatches to per-type loaders.
19+
20+
Constructed with a mapping from Python types to `DocumentLoader` instances.
21+
Dispatch uses `isinstance`, so a `pathlib.Path` registration matches
22+
`pathlib.PosixPath` / `WindowsPath`. The first matching entry in insertion
23+
order wins.
24+
25+
An unregistered input type raises `JsonLdError` with code
26+
`loading document failed` and details naming the registered types.
27+
28+
:param loaders: mapping of type to document loader.
29+
"""
30+
31+
loaders: Mapping[type, DocumentLoader]
32+
33+
def __call__(self, url: Any, options: dict | None = None) -> RemoteDocument:
34+
"""Retrieve the JSON-LD document at `url` via the matching type loader.
35+
36+
:param url: the URL, path, or other location to retrieve.
37+
:param options: loader options forwarded to the chosen loader.
38+
:return: a `RemoteDocument`.
39+
"""
40+
if options is None:
41+
options = {}
42+
43+
for typ, loader in self.loaders.items():
44+
if isinstance(url, typ):
45+
return loader(url, options)
46+
47+
raise JsonLdError(
48+
'URL could not be dereferenced; no loader is registered for '
49+
f'type "{type(url).__name__}".',
50+
'jsonld.InvalidUrl',
51+
{
52+
'url': url,
53+
'types': [typ.__name__ for typ in self.loaders],
54+
},
55+
code='loading document failed',
56+
)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Tests for TypeDirectedDocumentLoader."""
2+
3+
import json
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from pyld import (
9+
FileDocumentLoader,
10+
FrozenDocumentLoader,
11+
SchemeDirectedDocumentLoader,
12+
TypeDirectedDocumentLoader,
13+
jsonld,
14+
)
15+
from pyld.jsonld import JsonLdError
16+
17+
_CONTEXT_URL = 'https://example.com/context'
18+
_CONTEXT = {'@context': {'name': 'http://schema.org/name'}}
19+
_PERSON = {
20+
'@context': _CONTEXT_URL,
21+
'name': 'Ada Lovelace',
22+
}
23+
24+
25+
def test_dispatches_path_to_file_loader(tmp_path):
26+
"""A pathlib.Path is dispatched to the Path loader."""
27+
path = tmp_path / 'person.jsonld'
28+
path.write_text(json.dumps(_PERSON), encoding='utf-8')
29+
loader = TypeDirectedDocumentLoader(
30+
{
31+
Path: FileDocumentLoader(),
32+
}
33+
)
34+
35+
result = loader(path, {})
36+
37+
assert result['contentType'] == 'application/ld+json'
38+
assert json.loads(result['document']) == _PERSON
39+
assert result['documentUrl'] == path.resolve().as_uri()
40+
41+
42+
def test_path_subclass_matches_path_registration(tmp_path):
43+
"""A concrete Path subclass matches a Path registration via isinstance."""
44+
path = tmp_path / 'person.jsonld'
45+
path.write_text(json.dumps(_PERSON), encoding='utf-8')
46+
assert type(path) is not Path
47+
assert isinstance(path, Path)
48+
loader = TypeDirectedDocumentLoader(
49+
{
50+
Path: FileDocumentLoader(),
51+
}
52+
)
53+
54+
result = loader(path, {})
55+
56+
assert json.loads(result['document']) == _PERSON
57+
58+
59+
def test_dispatches_str_to_nested_scheme_loader():
60+
"""A str URL is dispatched to the str loader (e.g. by-scheme)."""
61+
http = FrozenDocumentLoader(documents={_CONTEXT_URL: _CONTEXT})
62+
loader = TypeDirectedDocumentLoader(
63+
{
64+
str: SchemeDirectedDocumentLoader(https=http),
65+
}
66+
)
67+
68+
result = loader(_CONTEXT_URL, {})
69+
70+
assert result['document'] == _CONTEXT
71+
assert result['documentUrl'] == _CONTEXT_URL
72+
73+
74+
def test_unregistered_type_raises():
75+
"""An unregistered input type raises JsonLdError naming registered types."""
76+
loader = TypeDirectedDocumentLoader(
77+
{
78+
Path: FileDocumentLoader(),
79+
}
80+
)
81+
82+
with pytest.raises(JsonLdError) as exc:
83+
loader('https://example.com/person.jsonld', {})
84+
85+
assert exc.value.code == 'loading document failed'
86+
assert exc.value.type == 'jsonld.InvalidUrl'
87+
assert exc.value.details['types'] == ['Path']
88+
89+
90+
def test_load_path_with_remote_context(tmp_path):
91+
"""Load a Path whose @context is an https URL via composed loaders."""
92+
path = tmp_path / 'person.jsonld'
93+
path.write_text(json.dumps(_PERSON), encoding='utf-8')
94+
file_loader = FileDocumentLoader()
95+
http = FrozenDocumentLoader(documents={_CONTEXT_URL: _CONTEXT})
96+
loader = TypeDirectedDocumentLoader(
97+
{
98+
Path: file_loader,
99+
str: SchemeDirectedDocumentLoader(
100+
file=file_loader,
101+
http=http,
102+
https=http,
103+
),
104+
}
105+
)
106+
107+
remote = jsonld.load_document(path, options={'documentLoader': loader})
108+
expanded = jsonld.expand(
109+
remote['document'],
110+
options={
111+
'documentLoader': loader,
112+
'base': remote['documentUrl'],
113+
},
114+
)
115+
116+
assert expanded == [{'http://schema.org/name': [{'@value': 'Ada Lovelace'}]}]

0 commit comments

Comments
 (0)