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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) for code style, linting (e.g. `make lint
- For brand-new test files, prefer function-based pytest tests (module-level `def test_...`) over class-based tests.
- Use descriptive test names that reflect behavior (e.g. `test_remote_context_via_link_alternate`).

## Document loaders

- Class-based composed Document Loaders accept only `DocumentLoader` instances — never plain
callables, and never type as `DocumentLoader | Callable`.

## Documentation

- Put docs-specific CSS in `docs/stylesheets/extra.css` and register it via `extra_css` in `mkdocs.yml`.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
- `*Options` TypedDict types and a `Context` type alias in `pyld.options` for JSON-LD API option dicts (typing and documentation).
- `pyld.SqliteCacheRequestsDocumentLoader`: a SQLite-backed HTTP cache document loader using `requests-cache`.
- `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.

### 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
4 changes: 4 additions & 0 deletions docs/examples/data/person_remote_context.jsonld
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"@context": "https://example.com/context",
"name": "Ada Lovelace"
}
31 changes: 31 additions & 0 deletions docs/examples/document_loaders/choice_by_scheme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import json
from pathlib import Path

from pyld import (
ChoiceBySchemeDocumentLoader,
FileDocumentLoader,
FrozenDocumentLoader,
jsonld,
)

person = (
Path(__file__).resolve().parent.parent / 'data' / 'person_remote_context.jsonld'
)

http = FrozenDocumentLoader(
documents={
'https://example.com/context': {
'@context': {'name': 'http://schema.org/name'},
},
}
)
loader = ChoiceBySchemeDocumentLoader(
file=FileDocumentLoader(),
http=http,
https=http,
)
result = jsonld.expand(
person.as_uri(),
options={'documentLoader': loader},
)
print(json.dumps(result, indent=2))
21 changes: 21 additions & 0 deletions docs/reference/document-loaders/choice-by-scheme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
hide: [toc]
---
# :material-call-split: `ChoiceBySchemeDocumentLoader`

::: pyld.ChoiceBySchemeDocumentLoader
options:
show_root_heading: false
show_bases: false
heading_level: 3
members: false

Compose per-scheme loaders so a local document can resolve a remote `@context`:

=== "Example"

{{ example('document_loaders/choice_by_scheme.py', output_syntax='json', indent=4) }}

=== "person_remote_context.jsonld"

{{ example_data('data/person_remote_context.jsonld', indent=4) }}
16 changes: 14 additions & 2 deletions docs/reference/document-loaders/file.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ hide: [toc]
heading_level: 3
members: false

{{ example('document_loaders/file_basic.py', output_syntax='json') }}
=== "Example"

{{ example('document_loaders/file_basic.py', output_syntax='json', indent=4) }}

=== "person.jsonld"

{{ example_data('data/person.jsonld', indent=4) }}

## Content Types

Expand All @@ -26,4 +32,10 @@ Pass `root` to refuse paths that resolve outside a directory. Resolved paths
and symlink targets are checked, so `..` traversal and symlink escapes are
rejected:

{{ example('document_loaders/file_root.py', output_syntax='json') }}
=== "Example"

{{ example('document_loaders/file_root.py', output_syntax='json', indent=4) }}

=== "person.jsonld"

{{ example_data('data/person.jsonld', indent=4) }}
6 changes: 6 additions & 0 deletions docs/reference/document-loaders/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ class-based loaders for common cases and supports custom subclasses of

Read local JSON-LD documents from `file:` URLs.

- [:material-call-split:{ .lg .middle } `ChoiceBySchemeDocumentLoader`](choice-by-scheme.md)

---

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

- [:material-code-braces:{ .lg .middle } __Custom Document Loaders__](custom.md)

---
Expand Down
20 changes: 20 additions & 0 deletions docs_macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,23 @@ def example(name, output_syntax=None, indent=0):
pad = ' ' * content_indent
indented = '\n'.join(f'{pad}{line}' for line in body.splitlines())
return f'!!! example "{title}"\n\n{indented}\n'

@env.macro
def example_data(name, indent=0):
path = _example_path(name)
source = path.read_text().rstrip('\n')
suffix = path.suffix.lower()
lang = 'json' if suffix in {'.json', '.jsonld'} else (
suffix.lstrip('.') or 'text'
)
github_url = _example_github_url(name, env.conf['repo_url'])
title = (
f'Source<span class="example-source-link" markdown>'
f':fontawesome-brands-github: [`{path.name}`]({github_url})'
f'</span>'
)
body = f'```{lang}\n{source}\n```'
content_indent = indent + 4
pad = ' ' * content_indent
indented = '\n'.join(f'{pad}{line}' for line in body.splitlines())
return f'!!! example "{title}"\n\n{indented}\n'
2 changes: 2 additions & 0 deletions lib/pyld/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .context_resolver import ContextResolver
from .documentloader.aiohttp import AioHttpDocumentLoader
from .documentloader.base import DocumentLoader, RemoteDocument
from .documentloader.choice_by_scheme import ChoiceBySchemeDocumentLoader
from .documentloader.file import FileDocumentLoader
from .documentloader.frozen import BUNDLED_CONTEXTS, FrozenDocumentLoader
from .documentloader.requests import RequestsDocumentLoader
Expand All @@ -12,6 +13,7 @@
__all__ = [
'AioHttpDocumentLoader',
'BUNDLED_CONTEXTS',
'ChoiceBySchemeDocumentLoader',
'ContextResolver',
'DocumentLoader',
'FileDocumentLoader',
Expand Down
53 changes: 53 additions & 0 deletions lib/pyld/documentloader/choice_by_scheme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
Scheme-dispatching JSON-LD document loader.

.. module:: jsonld.documentloader.choice_by_scheme
:synopsis: ChoiceBySchemeDocumentLoader for multi-scheme document loading
"""

from urllib.parse import urlparse

from pyld.documentloader.base import DocumentLoader, RemoteDocument
from pyld.jsonld import JsonLdError


class ChoiceBySchemeDocumentLoader(DocumentLoader):
"""Document loader that dispatches to per-scheme loaders.

Constructed as keyword arguments mapping each URL scheme name to a
`DocumentLoader` instance. Non-identifier scheme names can be passed via
``ChoiceBySchemeDocumentLoader(**{'view-source': loader})``.

An unregistered scheme raises `JsonLdError` with code
`loading document failed` and details naming the registered schemes.

:param loaders: keyword mapping of scheme name to document loader.
"""

def __init__(self, **loaders: DocumentLoader) -> None:
self.loaders = {scheme.lower(): loader for scheme, loader in loaders.items()}

def __call__(self, url: str, options: dict | None = None) -> RemoteDocument:
"""Retrieve the JSON-LD document at `url` via the matching scheme loader.

:param url: the URL string to retrieve.
:param options: loader options forwarded to the chosen loader.
:return: a `RemoteDocument`.
"""
if options is None:
options = {}

scheme = urlparse(url).scheme

try:
loader = self.loaders[scheme]
except KeyError:
raise JsonLdError(
'URL could not be dereferenced; no loader is registered for '
f'the "{scheme}" scheme.',
'jsonld.InvalidUrl',
{'url': url, 'schemes': sorted(self.loaders)},
code='loading document failed',
) from None

return loader(url, options)
100 changes: 100 additions & 0 deletions tests/test_choice_by_scheme_document_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Tests for ChoiceBySchemeDocumentLoader."""

import json
from pathlib import Path

import pytest

from pyld import (
ChoiceBySchemeDocumentLoader,
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 _file_url(path: Path) -> str:
return path.resolve().as_uri()


def test_dispatches_file_url(tmp_path):
"""A file: URL is dispatched to the file loader."""
path = tmp_path / 'person.jsonld'
path.write_text(json.dumps(_PERSON), encoding='utf-8')
loader = ChoiceBySchemeDocumentLoader(file=FileDocumentLoader())

result = loader(_file_url(path), {})

assert result['contentType'] == 'application/ld+json'
assert json.loads(result['document']) == _PERSON


def test_dispatches_https_url():
"""An https: URL is dispatched to the https loader."""
http = FrozenDocumentLoader(documents={_CONTEXT_URL: _CONTEXT})
loader = ChoiceBySchemeDocumentLoader(https=http)

result = loader(_CONTEXT_URL, {})

assert result['document'] == _CONTEXT
assert result['documentUrl'] == _CONTEXT_URL


def test_scheme_less_url_raises():
"""A scheme-less URL raises when no empty-scheme loader is registered."""
loader = ChoiceBySchemeDocumentLoader(file=FileDocumentLoader())

with pytest.raises(JsonLdError) as exc:
loader('/tmp/person.jsonld', {})

assert exc.value.code == 'loading document failed'
assert exc.value.details['schemes'] == ['file']


def test_unregistered_scheme_raises():
"""An unregistered scheme raises JsonLdError naming registered schemes."""
loader = ChoiceBySchemeDocumentLoader(file=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['schemes'] == ['file']


def test_scheme_matching_is_case_insensitive():
"""Registered scheme keys match uppercase URL schemes."""
http = FrozenDocumentLoader(documents={_CONTEXT_URL: _CONTEXT})
loader = ChoiceBySchemeDocumentLoader(HTTPS=http)

result = loader(_CONTEXT_URL, {})

assert result['document'] == _CONTEXT


def test_expand_local_file_with_remote_context(tmp_path):
"""Expand a local file whose @context is an https URL via composed loaders."""
path = tmp_path / 'person.jsonld'
path.write_text(json.dumps(_PERSON), encoding='utf-8')
http = FrozenDocumentLoader(documents={_CONTEXT_URL: _CONTEXT})
loader = ChoiceBySchemeDocumentLoader(
file=FileDocumentLoader(),
http=http,
https=http,
)

expanded = jsonld.expand(
_file_url(path),
options={'documentLoader': loader},
)

assert expanded == [{'http://schema.org/name': [{'@value': 'Ada Lovelace'}]}]
Loading