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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added
- `*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.

### 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
6 changes: 6 additions & 0 deletions docs/examples/data/person.jsonld
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"@context": {
"name": "http://schema.org/name"
},
"name": "Ada Lovelace"
}
13 changes: 13 additions & 0 deletions docs/examples/document_loaders/file_basic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import json
from pathlib import Path

from pyld import FileDocumentLoader, jsonld

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

loader = FileDocumentLoader()
result = jsonld.expand(
person.as_uri(),
options={'documentLoader': loader},
)
print(json.dumps(result, indent=2))
14 changes: 14 additions & 0 deletions docs/examples/document_loaders/file_root.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import json
from pathlib import Path

from pyld import FileDocumentLoader, jsonld

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

loader = FileDocumentLoader(root=data_dir)
result = jsonld.expand(
person.as_uri(),
options={'documentLoader': loader},
)
print(json.dumps(result, indent=2))
29 changes: 29 additions & 0 deletions docs/reference/document-loaders/file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
hide: [toc]
---
# :material-file-outline: `FileDocumentLoader`

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

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

## Content Types

The content type is chosen based on the file extension as follows:

{{ file_content_types_table() }}

Unsupported extensions raise `JsonLdError` with code `loading document failed`.

## Root Confinement

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') }}
9 changes: 6 additions & 3 deletions docs/reference/document-loaders/frozen.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# :material-snowflake: `FrozenDocumentLoader`

`FrozenDocumentLoader` serves only URLs in an allowlist and refuses all other
document loads. It is intended for air-gapped runs, reproducible builds, and
deployments that must avoid remote context fetching.
::: pyld.FrozenDocumentLoader
options:
show_root_heading: false
show_bases: false
heading_level: 3
members: false

With no arguments, the loader serves the curated `BUNDLED_CONTEXTS` mapping:

Expand Down
16 changes: 12 additions & 4 deletions docs/reference/document-loaders/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ class-based loaders for common cases and supports custom subclasses of

Serve only documents from an allowlist for air-gapped or reproducible runs.

- [:material-file-outline:{ .lg .middle } `FileDocumentLoader`](file.md)

---

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

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

---
Expand All @@ -42,7 +48,9 @@ class-based loaders for common cases and supports custom subclasses of

## Default Document Loader

The default document loader is selected at import time. PyLD uses
`RequestsDocumentLoader` if `requests` is available, falls back to
`AioHttpDocumentLoader` if `aiohttp` is available, and otherwise installs a
dummy loader that raises when invoked.
The default document loader is selected at import time, in this order:

1. [`RequestsDocumentLoader`](requests.md) if `requests` is available
2. [`AioHttpDocumentLoader`](aiohttp.md) if `aiohttp` is available

If neither is installed, document loading raises.
16 changes: 16 additions & 0 deletions docs_macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,22 @@ def bundled_contexts_table():
rows.append(f'| `{url}` | `{Path(path).name}` |')
return '\n'.join(rows)

@env.macro
def file_content_types_table():
from pyld.documentloader.file import CONTENT_TYPES

by_type: dict[str, list[str]] = {}
for extension, content_type in CONTENT_TYPES.items():
by_type.setdefault(content_type, []).append(f'`{extension}`')

rows = [
'| Extension | Content type |',
'| --- | --- |',
]
for content_type, extensions in by_type.items():
rows.append(f'| {", ".join(extensions)} | `{content_type}` |')
return '\n'.join(rows)

@env.macro
def skipped_tests_table():
from runtests import TEST_TYPES
Expand Down
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.file import FileDocumentLoader
from .documentloader.frozen import BUNDLED_CONTEXTS, FrozenDocumentLoader
from .documentloader.requests import RequestsDocumentLoader
from .documentloader.requests_sqlite_cache import SqliteCacheRequestsDocumentLoader
Expand All @@ -13,6 +14,7 @@
'BUNDLED_CONTEXTS',
'ContextResolver',
'DocumentLoader',
'FileDocumentLoader',
'FrozenDocumentLoader',
'RequestsDocumentLoader',
'RemoteDocument',
Expand Down
107 changes: 107 additions & 0 deletions lib/pyld/documentloader/file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""
Local filesystem document loader for ``file:`` URLs.

.. module:: jsonld.documentloader.file
:synopsis: FileDocumentLoader for local JSON-LD documents
"""

from pathlib import Path
from urllib.parse import urlparse
from urllib.request import url2pathname

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

CONTENT_TYPES = {
'.jsonld': 'application/ld+json',
'.json': 'application/json',
'.html': 'text/html',
'.htm': 'text/html',
'.xhtml': 'application/xhtml+xml',
}


class FileDocumentLoader(DocumentLoader):
"""Document loader that reads local files for `file:` URLs.

Accepts `file:` URLs, scheme-less absolute paths, and `pathlib.Path`
instances. Any other scheme raises `JsonLdError`. When `root` is set,
only paths that resolve under that directory are served.

:param root: optional directory that confines readable paths; when set,
paths that resolve outside this directory are refused.
"""

def __init__(self, root: str | Path | None = None) -> None:
self.root = Path(root).resolve() if root is not None else None

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

:param url: a `file:` URL, scheme-less absolute path, or
`pathlib.Path`.
:param options: loader options (unused; accepted for interface parity).
:return: a `RemoteDocument`.
"""
if options is None:
options = {}

fragment = ''
if isinstance(url, Path):
path = url.resolve()
else:
parts = urlparse(url)
if parts.scheme == 'file':
path_text = parts.path
elif parts.scheme == '':
path_text = url
else:
raise JsonLdError(
'URL could not be dereferenced; only "file" URLs are '
'supported.',
'jsonld.InvalidUrl',
{'url': url},
code='loading document failed',
)
path = Path(url2pathname(path_text)).resolve()
fragment = parts.fragment

if self.root is not None and not path.is_relative_to(self.root):
raise JsonLdError(
'URL could not be dereferenced; path is outside the '
'configured root.',
'jsonld.LoadDocumentError',
{'url': url, 'root': str(self.root)},
code='loading document failed',
)

content_type = CONTENT_TYPES.get(path.suffix.lower())
if content_type is None:
raise JsonLdError(
'URL could not be dereferenced; unsupported file extension.',
'jsonld.LoadDocumentError',
{'url': url, 'suffix': path.suffix},
code='loading document failed',
)

try:
document = path.read_text(encoding='utf-8')
except OSError as cause:
raise JsonLdError(
'Could not retrieve a JSON-LD document from the URL.',
'jsonld.LoadDocumentError',
{'url': url},
code='loading document failed',
) from cause

document_url = path.as_uri()
if fragment:
document_url = f'{document_url}#{fragment}'
return {
'contentType': content_type,
'contextUrl': None,
'documentUrl': document_url,
'document': document,
}
21 changes: 12 additions & 9 deletions lib/pyld/documentloader/frozen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
Frozen JSON-LD document loader.

A document loader that serves *only* the URLs in its ``documents`` allowlist
and refuses everything else with :class:`pyld.jsonld.JsonLdError`. Suitable for
secure / air-gapped / privacy-sensitive deployments and for honoring the
guidance in the W3C *JSON-LD Best Practices* note that clients SHOULD attempt
to use a locally cached version of contexts (§ Cache JSON-LD Contexts,
and refuses everything else with ``JsonLdError``. Suitable for secure /
air-gapped / privacy-sensitive deployments and for honoring the guidance in
the W3C *JSON-LD Best Practices* note that clients SHOULD attempt to use a
locally cached version of contexts (§ Cache JSON-LD Contexts,
https://w3c.github.io/json-ld-bp/#cache-json-ld-contexts).

This module also defines :data:`BUNDLED_CONTEXTS`, a curated mapping of
This module also defines ``BUNDLED_CONTEXTS``, a curated mapping of
high-traffic public W3C / W3ID JSON-LD context URLs to vendored on-disk copies
shipped with the package. See ``scripts/download_contexts.py`` for how the
files in ``bundled/`` are refreshed.
Expand Down Expand Up @@ -45,15 +45,18 @@ class FrozenDocumentLoader(DocumentLoader):
"""Document loader that serves only a sealed allowlist of URLs.

``documents`` maps each allowed URL to either a parsed JSON-LD ``dict`` or
a :class:`pathlib.Path` pointing to a JSON file on disk. Path entries are
read and parsed lazily on first request, then cached in place so subsequent
a ``pathlib.Path`` pointing to a JSON file on disk. Path entries are read
and parsed lazily on first request, then cached in place so subsequent
calls skip the file read. Any URL not present in the mapping raises
:class:`pyld.jsonld.JsonLdError` with code ``'loading document failed'``.
``JsonLdError`` with code ``'loading document failed'``.

With no arguments, a ``FrozenDocumentLoader`` serves the curated
:data:`BUNDLED_CONTEXTS` set. To extend rather than replace the bundle::
``BUNDLED_CONTEXTS`` set. To extend rather than replace the bundle::

FrozenDocumentLoader(documents=dict(BUNDLED_CONTEXTS, **extras))

:param documents: allowlist mapping each URL to a parsed JSON-LD ``dict``
or a ``pathlib.Path`` to a JSON file; defaults to ``BUNDLED_CONTEXTS``.
"""

def __init__(self, documents: Mapping[str, dict | Path] | None = None) -> None:
Expand Down
Loading
Loading