From 071c01bddf0c71b1c827ef4feb18bd6a6d9c9a12 Mon Sep 17 00:00:00 2001 From: Anatoly Scherbakov Date: Sun, 26 Jul 2026 21:26:49 +0400 Subject: [PATCH 1/3] Defer SqliteCacheRequestsDocumentLoader session creation until first load --- .../documentloader/requests_sqlite_cache.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/pyld/documentloader/requests_sqlite_cache.py b/lib/pyld/documentloader/requests_sqlite_cache.py index d99dfbcf..973aca0c 100644 --- a/lib/pyld/documentloader/requests_sqlite_cache.py +++ b/lib/pyld/documentloader/requests_sqlite_cache.py @@ -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 @@ -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/``. @@ -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) From 722fa757ad60cc3a7615c1ae6b2602f14d735c39 Mon Sep 17 00:00:00 2001 From: Anatoly Scherbakov Date: Sun, 26 Jul 2026 21:26:49 +0400 Subject: [PATCH 2/3] Test that SqliteCacheRequestsDocumentLoader does not create the cache file on init --- ...t_sqlite_cache_requests_document_loader.py | 62 +++++++++++++++---- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/tests/test_sqlite_cache_requests_document_loader.py b/tests/test_sqlite_cache_requests_document_loader.py index 3006a8d7..63e54b98 100644 --- a/tests/test_sqlite_cache_requests_document_loader.py +++ b/tests/test_sqlite_cache_requests_document_loader.py @@ -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 @@ -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') @@ -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): @@ -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 @@ -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 From 999856bc22a60470d28d504358979146cf6cebb6 Mon Sep 17 00:00:00 2001 From: Anatoly Scherbakov Date: Sun, 26 Jul 2026 21:26:49 +0400 Subject: [PATCH 3/3] Note lazy SQLite cache file creation in the unreleased changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d0c1c9..d62f8ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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).