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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
- `pyld.ChoiceByTypeDocumentLoader`: a document loader that dispatches by Python
input type (e.g. `pathlib.Path` vs `str`).

### Changed
- `SqliteCacheRequestsDocumentLoader` creates the SQLite cache file on first document load, not at construction time.

### 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).
- Inline contexts that try to redefine @context now raise an error. Fixes [expand#ter56](https://w3c.github.io/json-ld-api/tests/expand-manifest.html#ter56) and [toRdf#ter56](https://w3c.github.io/json-ld-api/tests/toRdf-manifest.html#ter56).
Expand Down
21 changes: 16 additions & 5 deletions lib/pyld/documentloader/requests_sqlite_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
.. module:: jsonld.documentloader.requests_sqlite_cache
:synopsis: Persistent SQLite HTTP caching for Requests document loader
"""
from functools import cached_property
from pathlib import Path

from pyld.documentloader.base import DocumentLoader, RemoteDocument
Expand All @@ -26,6 +27,9 @@ def _resolve_sqlite_file_path(sqlite_file_path: Path | None) -> Path:
class SqliteCacheRequestsDocumentLoader(DocumentLoader):
"""Remote document loader with persistent SQLite HTTP caching.

The cache file is created when the first document is loaded, not when the
loader is constructed.

:param secure: require all requests to use HTTPS (default: False).
:param sqlite_file_path: absolute path to the ``.sqlite`` cache file; when
omitted, defaults to the platform user cache directory under ``pyld/``.
Expand All @@ -37,19 +41,26 @@ def __init__(
*,
sqlite_file_path: Path | None = None,
):
self.secure = secure
self.sqlite_file_path = _resolve_sqlite_file_path(sqlite_file_path)

@cached_property
def session(self):
"""The ``requests_cache.CachedSession`` backing this loader."""
from requests_cache import CachedSession

path = _resolve_sqlite_file_path(sqlite_file_path)
self.session = CachedSession(
cache_name=str(path),
return CachedSession(
cache_name=str(self.sqlite_file_path),
backend='sqlite',
cache_control=True,
# Cache JSON-LD contexts persistently by default; Cache-Control and
# related response headers still override this when present.
expire_after=-1,
)
self._loader = RequestsDocumentLoader(
secure=secure, session=self.session)

@cached_property
def _loader(self) -> RequestsDocumentLoader:
return RequestsDocumentLoader(secure=self.secure, session=self.session)

def __call__(self, url, options=None) -> RemoteDocument:
return self._loader(url, options=options)
62 changes: 49 additions & 13 deletions tests/test_sqlite_cache_requests_document_loader.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for SqliteCacheRequestsDocumentLoader and HTTP cache behavior."""

import json
import sqlite3
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
Expand All @@ -23,9 +24,11 @@ class _ContextHandler(BaseHTTPRequestHandler):

def do_GET(self):
type(self).request_count += 1
body = json.dumps({
'@context': {'name': 'http://example.org/name'},
}).encode()
body = json.dumps(
{
'@context': {'name': 'http://example.org/name'},
}
).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/ld+json')
self.send_header('Cache-Control', 'max-age=3600')
Expand All @@ -52,33 +55,64 @@ def context_url():
def test_requests_document_loader_accepts_custom_session():
"""RequestsDocumentLoader accepts a CachedSession via session=."""
loader = RequestsDocumentLoader(
session=CachedSession(backend='memory', cache_control=True))
session=CachedSession(backend='memory', cache_control=True)
)
assert isinstance(loader, DocumentLoader)
assert callable(loader)
loader.session.close()


def test_sqlite_cache_requests_document_loader_is_document_loader():
def test_sqlite_cache_requests_document_loader_is_document_loader(tmp_path):
"""Sqlite loader is a DocumentLoader composing RequestsDocumentLoader."""
loader = SqliteCacheRequestsDocumentLoader()
loader = SqliteCacheRequestsDocumentLoader(
sqlite_file_path=tmp_path / 'contexts.sqlite',
)
assert isinstance(loader, DocumentLoader)
assert isinstance(loader._loader, RequestsDocumentLoader)
assert callable(loader)
assert isinstance(loader.session, CachedSession)
assert isinstance(loader._loader, RequestsDocumentLoader)
loader.session.close()


def test_sqlite_cache_file_is_not_created_on_init(tmp_path):
"""Constructing the loader touches neither the cache file nor its parent."""
cache_path = tmp_path / 'cache' / 'contexts.sqlite'
SqliteCacheRequestsDocumentLoader(sqlite_file_path=cache_path)
assert not cache_path.exists()
assert not cache_path.parent.exists()


def test_sqlite_cache_file_is_created_on_first_load(context_url, tmp_path):
"""The cache file appears once a document is actually loaded."""
cache_path = tmp_path / 'contexts.sqlite'
loader = SqliteCacheRequestsDocumentLoader(sqlite_file_path=cache_path)
loader(context_url)
assert cache_path.exists()
loader.session.close()


def test_unusable_sqlite_cache_path_raises_on_first_load(context_url, tmp_path):
"""An unopenable cache path fails the load instead of silently degrading."""
cache_path = tmp_path / 'contexts.sqlite'
cache_path.mkdir()
loader = SqliteCacheRequestsDocumentLoader(sqlite_file_path=cache_path)
with pytest.raises(sqlite3.Error):
loader(context_url)


def test_sqlite_cache_requests_document_loader_rejects_relative_sqlite_file_path():
"""Relative sqlite_file_path is rejected."""
with pytest.raises(ValueError, match='absolute path'):
SqliteCacheRequestsDocumentLoader(
sqlite_file_path=Path('relative.sqlite'))
SqliteCacheRequestsDocumentLoader(sqlite_file_path=Path('relative.sqlite'))


def test_sqlite_cache_file_path_is_resolved(tmp_path):
"""Absolute sqlite_file_path is normalized to a full path."""
sqlite_file_path = tmp_path / 'cache' / '..' / 'contexts.sqlite'
assert _resolve_sqlite_file_path(sqlite_file_path) == (
tmp_path / 'contexts.sqlite').resolve()
assert (
_resolve_sqlite_file_path(sqlite_file_path)
== (tmp_path / 'contexts.sqlite').resolve()
)


def test_http_cache_headers_serve_from_cache_with_cache_control(context_url):
Expand All @@ -88,7 +122,8 @@ def test_http_cache_headers_serve_from_cache_with_cache_control(context_url):
'test_memory_cache_control',
backend='memory',
cache_control=True,
))
)
)
loader(context_url)
loader(context_url)
assert _ContextHandler.request_count == 1
Expand All @@ -103,7 +138,8 @@ def test_http_cache_headers_without_cache_control_hits_server_twice(context_url)
backend='memory',
cache_control=False,
expire_after=0,
))
)
)
loader(context_url)
loader(context_url)
assert _ContextHandler.request_count == 2
Expand Down
Loading