diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 43ad16fa..5d06dc51 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -69,6 +69,7 @@ jobs: pytest tests/test_manifests.py --tests=./specifications/json-ld-api/tests --loader=${{ matrix.loader }} pytest tests/test_manifests.py --tests=./specifications/json-ld-framing/tests --loader=${{ matrix.loader }} pytest tests/test_manifests.py --tests=./specifications/normalization/tests --loader=${{ matrix.loader }} + pytest tests/test_manifests.py --tests=./specifications/rdf-canon/tests --loader=${{ matrix.loader }} pytest --ignore ./tests/test_manifests.py env: LOADER: ${{ matrix.loader }} diff --git a/.gitmodules b/.gitmodules index f4e5432a..87e7ce95 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "specifications/normalization"] path = specifications/normalization url = https://github.com/json-ld/normalization.git +[submodule "specifications/rdf-canon"] + path = specifications/rdf-canon + url = https://github.com/w3c/rdf-canon.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 96905ef0..426fb574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,60 @@ # pyld ChangeLog -## 3.3.0 - unreleased +## 4.0.0 - unreleased + +### Added + +- Migrate `canon.py` to `rdflib` + - Added `rdflib` dependency in all relevant config and code files. + - Added `util.py`: + - Added the functions `from_legacy_dataset()` and `to_legacy_dataset()` to convert an `rdflib.Dataset` to an RDFJS-like `dict` and back wherever needed. + - Added unittests in `tests/test_util.py` for these functions. +- Implement RDFC1.0 + - Added new class `RDFC10` (subclass of `URDNA2015`) in `canon.py` + - Added the RDFC1.0 test-suite in `tests/runtests.py` + - Added support for testing blank-node identifier maps. + - Added support for testing with different hashing algorithms + +### Removed + +- **BREAKING**: Removed the internal `pyld.nquads` parser/serializer module. + +### Fixed + +- Preserved RDF literal lexical forms when converting through RDFLib, including canonical double output, large numeric values, and compound literal handling. +- Fixed `iri_resolver.unresolve()` query/fragment reconstruction while cleaning up the resolver docstring. +- Invalid IRI base values within lists are now skipped when serializing to RDF. Fixes [toRdf#tli12](https://w3c.github.io/json-ld-api/tests/toRdf-manifest#tli12) and [toRdf#tli14](https://w3c.github.io/json-ld-api/tests/toRdf-manifest#tli14). +- Literals with invalid `@language` no longer output triples. Fixes [toRdf#twf05](https://w3c.github.io/json-ld-api/tests/toRdf-manifest#twf05). +- IRI validation now detects multiple # fragments so these IRI's are skipped during toRDF. Fixes [toRdf#te111](https://w3c.github.io/json-ld-api/tests/toRdf-manifest#te111) and [toRdf#te112](https://w3c.github.io/json-ld-api/tests/toRdf-manifest#te112). +- In `_create_node_map()`, ignored `@id: None` subjects are now dropped before they enter the node map to prevent `_graph_to_rdf()` from crashing when sorting mixed `None` and string keys. Fixes [toRdf#te122](https://w3c.github.io/json-ld-api/tests/toRdf-manifest#te122). +- Use a helper for generalized N-Quads normalization in the test runner, since rdflib cannot parse blank-node predicates. Fixes [toRdf#te075](https://w3c.github.io/json-ld-api/tests/toRdf-manifest#te075). + +### Changed +- **BREAKING**: Migrated `jsonld.to_rdf()`, `jsonld.from_rdf()`, and N-Quads parsing/serialization to use `rdflib.Dataset` and RDFLib terms directly. + - `jsonld.to_rdf()` now returns an `rdflib.Dataset` by default when no output `format` is requested. + - Added `legacyMode` to `jsonld.to_rdf()` to return the previous RDF.js-like dataset `dict`. + - `jsonld.from_rdf()` now accepts both `rdflib.Dataset` inputs and the previous legacy dataset `dict` shape. + - Updated RDF-related tests for RDFLib dataset and N-Quads serialization behavior. +- **BREAKING**: nquads input/output now delegates to RDFLib instead of PyLD’s custom parser and serializer. +- Migrate `canon.py` to `rdflib`: + - Now use `rdflib` for RDF term type checking (e.g., checking is something is a bnode), looping over triples/quads, nquads serialization and constructing RDF terms (custom deepcopy is no longer needed) + - Move nquads parsing from`JsonLdProcessor.normalize()` to `URDNA2015.main()` so all parsing and serialization is handled by the same class. + - Move the main logic to `URDNA2015._canonicalize(self, dataset: Dataset)` while keeping input and output in `URDNA2015.main()`. The method `URDNA2015._canonicalize(self, dataset: Dataset)` accepts an `rdflib.Dataset` and returns a tuple with + - the canonicalized result as a nquads `str` and + - the blank node identifier map as `dict`. + - The method `URDNA2015 .main(self, dataset: str | dict | Dataset, options)` now + - accepts a `rdflib.Dataset` object in addition to an nquads `str` or the original RDFJS-like`dict`. + - returns + - a `str`: the serialized nquads result, or + - a `dict`: the result as RDFJS-like dataset or the blank node identifier map when the new parameter `outputMap` is `True`. + - The hashing algorithm is now an class attribute `URDNA2015.hash_algorithm` so it configurable (required for RDFC1.0) + - The `permutations()` function now uses `itertools.permutations` instead of a custom implementation. + - Replacements for rdflib's `_nq_row` and `_quoteLiteral` (these should eventually move to a fix for rdflib's nquads serializer). + - Re-enabled all skipped URDNA2015, URDNA2012 tests in `tests/runtests.py` +- If the result of a test is a dict and the expected value is a string, the expected value is now parsed as JSON (needed for testing blank-node identifier maps). +- **BREAKING**: Removed small Python 2-era compatibility code and simplified a few internal collection checks. + +## 3.3.0 - 2026-09-02 ### Added - `pyld.FileDocumentLoader`: a document loader for local `file:` URLs with optional root confinement. diff --git a/README.md b/README.md index 76c38deb..fca45e7f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ [Documentation](https://digitalbazaar.github.io/pyld/) | [Installation](#installation) | [Usage](#usage) | [Advanced -Topics](#advanced-topics) | [Contributing](./CONTRIBUTING.md) | +Topics](#advanced-topics) | [What's new in version 4](#whats-new-in-version-4) | +[Contributing](./CONTRIBUTING.md) | [Changelog](./CHANGELOG.md) ## Introduction @@ -41,9 +42,31 @@ memory footprint in order to operate. ## Requirements * Python (3.10 or later) +* rdflib (7.3 or later) * [Requests](http://docs.python-requests.org/) (optional) * [aiohttp](https://aiohttp.readthedocs.io/) (optional) +## What's new in version 4 + +PyLD 4 moves RDF handling to [RDFLib](https://rdflib.readthedocs.io/) and adds +RDF Dataset Canonicalization 1.0 support. + +The main developer-facing changes are: + +* `jsonld.to_rdf()` returns an `rdflib.Dataset` by default when no output + `format` is requested. +* `jsonld.to_rdf(doc, {'legacyMode': True})` returns the RDF.js-like dataset + `dict` used by PyLD versions lower than 4. +* `jsonld.from_rdf()` accepts `rdflib.Dataset`, N-Quads strings, and the legacy + dataset `dict`. +* `jsonld.normalize()` defaults to `RDFC10`; pass `{'algorithm': 'URDNA2015'}` + if older canonicalization output must remain stable. +* The internal `pyld.nquads` parser/serializer module has been removed. Use the + JSON-LD APIs with `format: 'application/n-quads'` or RDFLib directly. + +See the full [version 4 upgrade notes](https://digitalbazaar.github.io/pyld/whats-new-version-4/) +and the [`4.0.0` changelog entry](./CHANGELOG.md#400---unreleased). + ## Installation PyLD can be installed with a [pip](http://www.pip-installer.org/) @@ -138,10 +161,10 @@ flattened = jsonld.flatten(doc) framed = jsonld.frame(doc, frame) # document transformed into a particular tree structure per the given frame -# normalize a document using the RDF Dataset Normalization Algorithm -# (URDNA2015), see: https://www.w3.org/TR/rdf-canon/ +# normalize a document using RDF Dataset Canonicalization 1.0 +# (RDFC10), see: https://www.w3.org/TR/rdf-canon/ normalized = jsonld.normalize( - doc, {'algorithm': 'URDNA2015', 'format': 'application/n-quads'}) + doc, {'algorithm': 'RDFC10', 'format': 'application/n-quads'}) # normalized is a string that is a canonical representation of the document # that can be used for hashing, comparison, etc. @@ -153,6 +176,15 @@ rdf = jsonld.to_rdf( # flatten() and frame() accept the same identifierIssuer option ``` +`jsonld.to_rdf()` returns an `rdflib.Dataset` when no output `format` is +requested. To keep the RDF.js-like dataset `dict` used by PyLD versions lower +than 4, pass +`{'legacyMode': True}`: + +```python +dataset = jsonld.to_rdf(doc, {'legacyMode': True}) +``` + ## Features & conformance This library aims to conform with the following W3C Recommendations: diff --git a/docs/.pages b/docs/.pages index bc63cf2a..9ec24172 100644 --- a/docs/.pages +++ b/docs/.pages @@ -1,11 +1,14 @@ nav: - Quickstart: - index.md + - whats-new-version-4.md - installation.md - conformance.md - - API: - - reference - CLI: - cli + - Tutorials: + - tutorials + - API: + - reference - Project: - project diff --git a/docs/examples/context_resolver_sqlite_cache.py b/docs/examples/context_resolver_sqlite_cache.py new file mode 100644 index 00000000..609ee559 --- /dev/null +++ b/docs/examples/context_resolver_sqlite_cache.py @@ -0,0 +1,21 @@ +import json +from pathlib import Path + +from cachetools import LRUCache + +from pyld import ContextResolver, SqliteCacheRequestsDocumentLoader, jsonld + +loader = SqliteCacheRequestsDocumentLoader( + sqlite_file_path=Path("/tmp/pyld_example_context_cache.sqlite"), +) +resolver = ContextResolver(LRUCache(maxsize=1000), loader) + +doc = { + "@context": "https://schema.org/", + "name": "Example Person", +} +options = {"documentLoader": loader, "contextResolver": resolver} + +expanded = jsonld.expand(doc, options=options) + +print(json.dumps(expanded, indent=2)) diff --git a/docs/examples/document_loaders/caching_modes.py b/docs/examples/document_loaders/caching_modes.py new file mode 100644 index 00000000..85fb03e1 --- /dev/null +++ b/docs/examples/document_loaders/caching_modes.py @@ -0,0 +1,31 @@ +import json +import tempfile +from pathlib import Path +from uuid import uuid4 + +from requests_cache import CachedSession + +from pyld import ( + RequestsDocumentLoader, + SqliteCacheRequestsDocumentLoader, + jsonld, +) + +doc = { + "@context": {"name": "https://schema.org/name"}, + "name": "Earth", +} + +no_cache_loader = RequestsDocumentLoader() + +memory_cache_loader = RequestsDocumentLoader( + session=CachedSession(backend="memory", cache_control=True), +) + +persistent_cache_loader = SqliteCacheRequestsDocumentLoader( + sqlite_file_path=Path(tempfile.gettempdir()) / f"pyld-{uuid4()}.sqlite", +) + +result = jsonld.expand(doc, options={"documentLoader": persistent_cache_loader}) + +print(json.dumps(result, indent=2)) diff --git a/docs/examples/from_rdf_dataset.py b/docs/examples/from_rdf_dataset.py new file mode 100644 index 00000000..58e8c6e7 --- /dev/null +++ b/docs/examples/from_rdf_dataset.py @@ -0,0 +1,15 @@ +import json + +from rdflib import Dataset + +from pyld import jsonld + +nquads = ( + ' ' + ' "Earth" .\n' +) +dataset = Dataset().parse(data=nquads, format="nquads") + +doc = jsonld.from_rdf(dataset) + +print(json.dumps(doc, indent=2)) diff --git a/docs/examples/inject_context.py b/docs/examples/inject_context.py new file mode 100644 index 00000000..cc3478ff --- /dev/null +++ b/docs/examples/inject_context.py @@ -0,0 +1,16 @@ +import json + +from pyld import jsonld + +doc = { + "name": "Ada Lovelace", + "homepage": "https://example.com/ada", +} +context = { + "name": "http://schema.org/name", + "homepage": {"@id": "http://schema.org/url", "@type": "@id"}, +} + +expanded = jsonld.expand(doc, {"expandContext": context}) + +print(json.dumps(expanded, indent=2)) diff --git a/docs/examples/inject_context_compact.py b/docs/examples/inject_context_compact.py new file mode 100644 index 00000000..06cbc579 --- /dev/null +++ b/docs/examples/inject_context_compact.py @@ -0,0 +1,24 @@ +import json + +from pyld import jsonld + +doc = { + "name": "Ada Lovelace", + "homepage": "https://example.com/ada", +} +source_context = { + "name": "http://schema.org/name", + "homepage": {"@id": "http://schema.org/url", "@type": "@id"}, +} +output_context = { + "name": "http://schema.org/name", + "url": {"@id": "http://schema.org/url", "@type": "@id"}, +} + +compacted = jsonld.compact( + doc, + output_context, + {"expandContext": source_context}, +) + +print(json.dumps(compacted, indent=2)) diff --git a/docs/examples/legacy_helpers.py b/docs/examples/legacy_helpers.py new file mode 100644 index 00000000..95375563 --- /dev/null +++ b/docs/examples/legacy_helpers.py @@ -0,0 +1,20 @@ +import json + +from pyld.util import from_legacy_dataset, to_legacy_dataset + +legacy_dataset = { + 'http://example.org': [ + { + 'subject': {'type': 'blank node', 'value': '_:s1'}, + 'predicate': {'type': 'IRI', 'value': 'p1'}, + 'object': {'type': 'literal', 'value': 'o1'}, + } + ] +} + + +rdflib_dataset = from_legacy_dataset(legacy_dataset) +print(rdflib_dataset.serialize(format="nquads")) + +legacy_dataset = to_legacy_dataset(rdflib_dataset) +print(json.dumps(legacy_dataset, indent=2)) diff --git a/docs/examples/normalize.py b/docs/examples/normalize.py index a6aa6d95..dcb600db 100644 --- a/docs/examples/normalize.py +++ b/docs/examples/normalize.py @@ -9,6 +9,15 @@ }, } +# Normalize with RDFC10 +normalized = jsonld.normalize( + doc, + {"format": "application/n-quads"}, +) + +print(normalized) + +# Normalize with URDNA2015 normalized = jsonld.normalize( doc, {"algorithm": "URDNA2015", "format": "application/n-quads"}, diff --git a/docs/examples/normalize_output_map.py b/docs/examples/normalize_output_map.py new file mode 100644 index 00000000..0ebbeea6 --- /dev/null +++ b/docs/examples/normalize_output_map.py @@ -0,0 +1,19 @@ +import json + +from pyld import jsonld + +doc = { + "@type": "http://schema.org/Person", + "http://schema.org/name": "Manu Sporny", + "http://schema.org/url": {"@id": "http://manu.sporny.org/"}, + "http://schema.org/image": { + "@id": "http://manu.sporny.org/images/manu.png" + }, +} + +identifier_map = jsonld.normalize( + doc, + {"algorithm": "RDFC10", "outputMap": True}, +) + +print(json.dumps(identifier_map, indent=2)) diff --git a/docs/examples/open_jsonld_file.py b/docs/examples/open_jsonld_file.py new file mode 100644 index 00000000..e8c64eac --- /dev/null +++ b/docs/examples/open_jsonld_file.py @@ -0,0 +1,11 @@ +import json +from pathlib import Path + +from pyld import jsonld + +path = Path(__file__).resolve().parent / "data" / "person.jsonld" +doc = json.loads(path.read_text()) + +expanded = jsonld.expand(doc) + +print(json.dumps(expanded, indent=2)) diff --git a/docs/examples/open_jsonld_file_to_rdf.py b/docs/examples/open_jsonld_file_to_rdf.py new file mode 100644 index 00000000..8b92e478 --- /dev/null +++ b/docs/examples/open_jsonld_file_to_rdf.py @@ -0,0 +1,11 @@ +import json +from pathlib import Path + +from pyld import jsonld + +path = Path(__file__).resolve().parent / "data" / "person.jsonld" +doc = json.loads(path.read_text()) + +nquads = jsonld.to_rdf(doc, {"format": "application/n-quads"}) + +print(nquads) diff --git a/docs/examples/persistent_document_loader_cache.py b/docs/examples/persistent_document_loader_cache.py new file mode 100644 index 00000000..bdb3ab39 --- /dev/null +++ b/docs/examples/persistent_document_loader_cache.py @@ -0,0 +1,18 @@ +import json +import tempfile +from pathlib import Path +from uuid import uuid4 + +from pyld import SqliteCacheRequestsDocumentLoader, jsonld + +cache_path = Path(tempfile.gettempdir()) / f"pyld-{uuid4()}.sqlite" +loader = SqliteCacheRequestsDocumentLoader(sqlite_file_path=cache_path) + +doc = { + "@context": {"name": "https://schema.org/name"}, + "name": "Earth", +} + +expanded = jsonld.expand(doc, options={"documentLoader": loader}) + +print(json.dumps(expanded, indent=2)) diff --git a/docs/examples/rdflib_parse.py b/docs/examples/rdflib_parse.py new file mode 100644 index 00000000..e76182e9 --- /dev/null +++ b/docs/examples/rdflib_parse.py @@ -0,0 +1,16 @@ +import json + +from rdflib import Dataset + +from pyld import jsonld + +nquads = ( + ' ' + ' "Earth" .\n' +) +dataset = Dataset().parse(data=nquads, format="nquads") + +doc = jsonld.from_rdf(dataset) +compacted = jsonld.compact(doc, {"name": "http://schema.org/name"}) + +print(json.dumps(compacted, indent=2)) diff --git a/docs/examples/rdflib_processing.py b/docs/examples/rdflib_processing.py new file mode 100644 index 00000000..71eebfa0 --- /dev/null +++ b/docs/examples/rdflib_processing.py @@ -0,0 +1,28 @@ +import json + +from rdflib import Namespace, URIRef + +from pyld import jsonld + +SCHEMA = Namespace("http://schema.org/") +subject = URIRef("http://dbpedia.org/resource/Earth") + +doc = { + "@context": {"name": str(SCHEMA.name)}, + "@id": str(subject), + "name": "Earth", +} + +dataset = jsonld.to_rdf(doc) +dataset.add((subject, SCHEMA.url, URIRef("https://example.com/earth"))) + +jsonld_doc = jsonld.from_rdf(dataset) +compacted = jsonld.compact( + jsonld_doc, + { + "name": str(SCHEMA.name), + "homepage": {"@id": str(SCHEMA.url), "@type": "@id"}, + }, +) + +print(json.dumps(compacted, indent=2)) diff --git a/docs/examples/rdflib_query.py b/docs/examples/rdflib_query.py new file mode 100644 index 00000000..bef96a87 --- /dev/null +++ b/docs/examples/rdflib_query.py @@ -0,0 +1,19 @@ +import json + +from rdflib import Namespace, URIRef + +from pyld import jsonld + +SCHEMA = Namespace("http://schema.org/") +subject = URIRef("http://dbpedia.org/resource/Earth") + +doc = { + "@context": {"name": str(SCHEMA.name)}, + "@id": str(subject), + "name": "Earth", +} + +dataset = jsonld.to_rdf(doc) +names = [str(value) for value in dataset.objects(subject, SCHEMA.name)] + +print(json.dumps(names, indent=2)) diff --git a/docs/examples/to_rdf_dataset.py b/docs/examples/to_rdf_dataset.py new file mode 100644 index 00000000..9f495652 --- /dev/null +++ b/docs/examples/to_rdf_dataset.py @@ -0,0 +1,14 @@ +from pyld import jsonld + +doc = { + "@context": { + "name": "http://schema.org/name", + }, + "@id": "http://dbpedia.org/resource/Earth", + "name": "Earth", +} + +dataset = jsonld.to_rdf(doc) + +print(type(dataset).__name__) +print(dataset.serialize(format="nquads")) diff --git a/docs/examples/to_rdf_legacy.py b/docs/examples/to_rdf_legacy.py new file mode 100644 index 00000000..735f87be --- /dev/null +++ b/docs/examples/to_rdf_legacy.py @@ -0,0 +1,15 @@ +import json + +from pyld import jsonld + +doc = { + "@context": { + "name": "http://schema.org/name", + }, + "@id": "http://dbpedia.org/resource/Earth", + "name": "Earth", +} + +legacy_dataset = jsonld.to_rdf(doc, {"legacyMode": True}) + +print(json.dumps(legacy_dataset, indent=2)) diff --git a/docs/index.md b/docs/index.md index 8be5cfac..bd0fba85 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,6 +16,15 @@ applications add meaning to existing JSON documents with in-band or out-of-band contexts, while keeping the document shape practical for web APIs, JavaScript, and JSON document stores. +## :material-new-box: What's new in version 4 + +PyLD 4 uses [:material-library: RDFLib](https://rdflib.readthedocs.io/) +`rdflib.Dataset` as the native RDF dataset representation, adds `RDFC10` +canonicalization, and removes the internal `pyld.nquads` parser/serializer +module. + +[Read the version 4 upgrade notes :octicons-arrow-right-24:](whats-new-version-4.md) + ## :material-lightning-bolt: Quick Examples === "jsonld.compact()" diff --git a/docs/reference/context-resolver.md b/docs/reference/context-resolver.md index c20a85bb..f45700a3 100644 --- a/docs/reference/context-resolver.md +++ b/docs/reference/context-resolver.md @@ -16,6 +16,12 @@ documents, so pass the same loader with `documentLoader`. {{ example('context_resolver.py', output_syntax='json') }} +Use the built-in `SqliteCacheRequestsDocumentLoader` when you want the resolver +to combine PyLD's in-memory resolved-context cache with persistent HTTP caching +for fetched remote contexts. + +{{ example('context_resolver_sqlite_cache.py', output_syntax='json') }} + The `shared_cache` object must behave like a mutable mapping. PyLD uses `cachetools.LRUCache` by default, but applications can provide another mapping when they need a different eviction policy. diff --git a/docs/reference/from_rdf.md b/docs/reference/from_rdf.md index 52ad7aba..b241dc3b 100644 --- a/docs/reference/from_rdf.md +++ b/docs/reference/from_rdf.md @@ -12,6 +12,17 @@ show_bases: false heading_level: 3 -## Example +`jsonld.from_rdf()` accepts a +[:material-library: RDFLib](https://rdflib.readthedocs.io/) `rdflib.Dataset`, +an N-Quads string, or the RDF.js-like dataset `dict` returned by PyLD versions +lower than 4. Prefer `rdflib.Dataset` for new in-memory RDF code. + +## Examples + +### Parsing N-Quads as JSON-LD {{ example('from_rdf.py', 'json') }} + +### Transforming an `rdflib.Dataset` into JSON-LD + +{{ example('from_rdf_dataset.py', 'json') }} diff --git a/docs/reference/normalize.md b/docs/reference/normalize.md index 7b05cad5..d1a7d830 100644 --- a/docs/reference/normalize.md +++ b/docs/reference/normalize.md @@ -12,6 +12,15 @@ show_bases: false heading_level: 3 -## Example +`RDFC10` is the default algorithm in PyLD 4. Use `URDNA2015` or `URGNA2012` +explicitly when an integration requires the older canonicalization output. + +## Examples + +### Normalizing RDF as canonical N-Quads {{ example('normalize.py') }} + +### Outputting the Identifier Map of the canonicalization process (RDFC1.0) + +{{ example('normalize_output_map.py', 'json') }} diff --git a/docs/reference/to_rdf.md b/docs/reference/to_rdf.md index f3e4a04c..8dddd160 100644 --- a/docs/reference/to_rdf.md +++ b/docs/reference/to_rdf.md @@ -12,6 +12,24 @@ show_bases: false heading_level: 3 -## Example +When `format` is not set, `jsonld.to_rdf()` returns a +[:material-library: RDFLib](https://rdflib.readthedocs.io/) `rdflib.Dataset`. +Set `legacyMode` to `True` to return the RDF.js-like dataset `dict` used by +PyLD versions lower than 4. + +`legacyMode` only affects the unformatted return value. If `format` is set, +`jsonld.to_rdf()` returns the requested serialization. + +## Examples + +### Serializing JSON-LD to N-Quads {{ example('to_rdf.py') }} + +### Transforming JSON-LD into an `rdflib.Dataset` + +{{ example('to_rdf_dataset.py') }} + +### Transforming JSON-LD into an legacy RDF.js-like dict + +{{ example('to_rdf_legacy.py', 'json') }} diff --git a/docs/tutorials/document-loader-caching.md b/docs/tutorials/document-loader-caching.md new file mode 100644 index 00000000..c6f3c5e1 --- /dev/null +++ b/docs/tutorials/document-loader-caching.md @@ -0,0 +1,54 @@ +# :material-cached: Caching remote contexts + +Remote JSON-LD contexts are normal documents loaded through a `DocumentLoader`. +Caching therefore belongs in two places: + +1. PyLD's `ContextResolver` caches resolved contexts during processing. +2. The `DocumentLoader` can cache HTTP responses before PyLD sees them. + +Use `RequestsDocumentLoader` when you want no HTTP cache. Use +`RequestsDocumentLoader` with a `requests_cache.CachedSession` for in-memory +HTTP caching. Use `SqliteCacheRequestsDocumentLoader` when the cache should +survive process restarts. + +For HTTP caching, install the necessary modules first by running: + +```bash +pip install "PyLD[requests-cache]" +``` + +## Choose a Cache Mode + +| Mode | Loader | Reused after process restart? | +| --- | --- | --- | +| No HTTP cache | `RequestsDocumentLoader()` | No | +| In-memory HTTP cache | `RequestsDocumentLoader(session=CachedSession(backend="memory"))` | No | +| Persistent HTTP cache | `SqliteCacheRequestsDocumentLoader()` | Yes | + +`SqliteCacheRequestsDocumentLoader` uses +[:simple-sqlite: SQLite](https://www.sqlite.org/) through +[:simple-pypi: `requests-cache`](https://pypi.org/project/requests-cache/). +When `sqlite_file_path` is omitted, it stores the cache in the platform user +cache directory. + +## Usage + +This example configures all three modes and passes the persistent loader through +`documentLoader`. The document uses an inline `@context` so the example is safe +to run without network access; use the same `options` shape when the `@context` +is a remote URL. + +{{ example('document_loaders/caching_modes.py', output_syntax='json') }} + +For application code, pass the selected loader in the operation `options`: + +{{ example('persistent_document_loader_cache.py', output_syntax='json') }} + +HTTP cache headers such as `Cache-Control`, `Expires`, and validators are +handled by the cached session. PyLD still applies its normal JSON-LD processing +rules after the remote document is loaded. + +??? note "Related reference" + See [`RequestsDocumentLoader`](../reference/document-loaders/requests.md), + [`SqliteCacheRequestsDocumentLoader`](../reference/document-loaders/sqlite-cache-requests.md), + and [`ContextResolver`](../reference/context-resolver.md). diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md new file mode 100644 index 00000000..cd5b85e1 --- /dev/null +++ b/docs/tutorials/index.md @@ -0,0 +1,37 @@ +--- +hide: [toc] +--- + +# :material-school-outline: Tutorials + +
+ +- [:material-library:{ .lg .middle } RDFLib and `jsonld`](rdflib-processing.md) + + --- + + Convert JSON-LD to an `rdflib.Dataset`, process it with RDFLib, and convert + it back to JSON-LD. + +- [:material-code-json:{ .lg .middle } Inject a JSON-LD `@context`](inject-context.md) + + --- + + Apply a context to plain JSON when the source cannot provide an in-band + `@context` or HTTP `Link` header. + +- [:material-file-code-outline:{ .lg .middle } Open a JSON-LD file](open-jsonld-file.md) + + --- + + Load a local `.jsonld` file, process it with PyLD, and choose the right + dependencies for local or remote contexts. + +- [:material-cached:{ .lg .middle } Cache remote contexts with `DocumentLoader`](document-loader-caching.md) + + --- + + Combine PyLD's context cache with no-cache, in-memory, and persistent + document-loader HTTP caching. + +
diff --git a/docs/tutorials/inject-context.md b/docs/tutorials/inject-context.md new file mode 100644 index 00000000..ffd43430 --- /dev/null +++ b/docs/tutorials/inject-context.md @@ -0,0 +1,26 @@ +# :material-code-json: Inject a JSON-LD `@context` + +JSON-LD property names only become RDF terms when a context maps them to IRIs. +If the input JSON has no `@context`, and the source cannot provide one through +an HTTP `Link` header, pass the context with `expandContext`. + +Use this for JSON already loaded from a file, queue, database, or API client: + +1. Load or receive the plain JSON document. +2. Define the context your application wants to apply. +3. Pass that context in the operation `options` as `expandContext`. +4. Run `jsonld.expand()` or another API that expands internally. + +PyLD processes `expandContext` before expansion. If the document also has its +own `@context`, that document context is processed later and can refine or +override term definitions for the document body. + +{{ example('inject_context.py', 'json') }} + +The same option works with APIs that expand internally, including +`jsonld.compact()`, `jsonld.flatten()`, and `jsonld.frame()`: + +{{ example('inject_context_compact.py', 'json') }} + +Use an in-document `@context` when you control the JSON. Use `expandContext` +when the context is application policy or transport metadata. diff --git a/docs/tutorials/open-jsonld-file.md b/docs/tutorials/open-jsonld-file.md new file mode 100644 index 00000000..99e7cf9c --- /dev/null +++ b/docs/tutorials/open-jsonld-file.md @@ -0,0 +1,36 @@ +# :material-file-code-outline: Open a JSON-LD file + +For a local `.jsonld` file that already contains its `@context`, use Python's +standard `json` module first. PyLD processes Python objects, so the usual order +is: + +```bash +pip install PyLD +``` + +1. Read the file with `pathlib.Path`. +2. Parse it with `json.loads()` or `json.load()`. +3. Pass the parsed object to `jsonld.expand()`, `jsonld.compact()`, + `jsonld.to_rdf()`, or another PyLD API. + +{{ example('open_jsonld_file.py', 'json') }} + +After parsing the file, use any PyLD operation. For example, convert the local +JSON-LD file to N-Quads: + +{{ example('open_jsonld_file_to_rdf.py') }} + +That is enough for local files with inline contexts. Choose a document loader +only when PyLD must dereference a URL during processing. + +If the document uses remote contexts, install and pass +`RequestsDocumentLoader`: + +```bash +pip install "PyLD[requests]" +``` + +If PyLD should load the JSON-LD document itself from a `file:` URL, use +[`FileDocumentLoader`](../reference/document-loaders/file.md) and pass it with +`documentLoader` in the operation `options`. Direct `json.load()` is simpler +when you already know the file path and only need to process that one file. diff --git a/docs/tutorials/rdflib-processing.md b/docs/tutorials/rdflib-processing.md new file mode 100644 index 00000000..a3a35b65 --- /dev/null +++ b/docs/tutorials/rdflib-processing.md @@ -0,0 +1,42 @@ +# :material-library: RDFLib and `jsonld` + +Use this pattern when JSON-LD is your interchange format, but RDF tooling is the +best place to query, update, serialize, or store the graph. + +Install PyLD: + +```bash +pip install PyLD +``` + +PyLD 4 installs and uses [:material-library: RDFLib](https://rdflib.readthedocs.io/) +for RDF datasets. The processing order is: + +1. Start with JSON-LD that has a usable `@context`. +2. Convert it with `jsonld.to_rdf()`. +3. Process the returned `rdflib.Dataset` with RDFLib. +4. Convert it back with `jsonld.from_rdf()` if the next layer expects JSON-LD. +5. Compact the result with `jsonld.compact()` if developers or APIs should see + terms such as `name` instead of full IRIs. + +{{ example('rdflib_processing.py', 'json') }} + +For simple lookups, use RDFLib's graph methods on the dataset returned by +`jsonld.to_rdf()`: + +{{ example('rdflib_query.py', 'json') }} + +For RDF that starts outside PyLD, parse it with RDFLib first, then pass the +`rdflib.Dataset` to `jsonld.from_rdf()`: + +{{ example('rdflib_parse.py', 'json') }} + +Use `format` only when you need a serialized RDF string instead of an in-memory +`rdflib.Dataset`: + +```python +nquads = jsonld.to_rdf(doc, {"format": "application/n-quads"}) +``` + +Use `jsonld.from_rdf(dataset)` directly when you already have an +`rdflib.Dataset` from another RDFLib parser, store, or query result. diff --git a/docs/whats-new-version-4.md b/docs/whats-new-version-4.md new file mode 100644 index 00000000..d2ff3050 --- /dev/null +++ b/docs/whats-new-version-4.md @@ -0,0 +1,98 @@ +# :material-new-box: What's new in version 4 + +PyLD 4 moves the RDF datastructure to [:material-library: RDFLib](https://rdflib.readthedocs.io/) +and adds RDF Dataset Canonicalization 1.0 support. The JSON-LD document APIs +remain the same, but RDF-facing code should review return types, canonicalization +defaults, and N-Quads behavior before upgrading. + +## RDFLib datasets are now the native RDF model + +`jsonld.to_rdf()` returns an `rdflib.Dataset` by default when `format` is not +set. In PyLD 3.x and earlier, it returned a RDF.js-like nested `dict`. + +{{ example('to_rdf_dataset.py') }} + +Request N-Quads when you need a serialized string: + +{{ example('to_rdf.py') }} + +Use `legacyMode` when existing code still expects the PyLD 3.x dataset `dict`: + +{{ example('to_rdf_legacy.py', 'json') }} + +`jsonld.from_rdf()` accepts an `rdflib.Dataset`, an N-Quads string, or the +legacy dataset `dict`. New code should prefer `rdflib.Dataset` for in-memory RDF +work and `application/n-quads` for process or storage boundaries. + +## `RDFC10` is available and is the normalization default + +`jsonld.normalize()` now defaults to `RDFC10`, the RDF Dataset Canonicalization +1.0 algorithm. `URDNA2015` and `URGNA2012` remain available by setting +`algorithm` explicitly. + +{{ example('normalize.py') }} + +For RDFC 1.0 test vectors and integrations that need the canonical blank node +identifier map, pass `outputMap`: + +{{ example('normalize_output_map.py', 'json') }} + +`RDFC10` also accepts `hashAlgorithm` for test suites and specialized +integrations. Most applications should keep the default SHA-256 behavior. + +## N-Quads parsing and serialization delegates to RDFLib + +PyLD 4 removes the internal `pyld.nquads` parser and serializer module. Public +JSON-LD APIs still accept and produce N-Quads through `format: "application/n-quads"`, +but imports from `pyld.nquads` need to be removed. + +If you previously used `pyld.nquads` directly, replace it with one of these +paths: + +- Use `jsonld.from_rdf(nquads, {"format": "application/n-quads"})` to convert + N-Quads to JSON-LD. +- Use `jsonld.to_rdf(doc, {"format": "application/n-quads"})` to serialize + JSON-LD as N-Quads. +- Use `jsonld.parse_nquads(doc, {"legacyMode": True})` to convert nquads + to a RDF.js-like nested `dict` from PyLD 3.x and earlier. + Omit `legacyMode` to return an `rdflib.Dataset`. + This method preserves blank node identifiers from the input document. +- Use `rdflib.Dataset().parse(data=nquads, format="nquads")` or + `rdflib.plugins.parsers.nquads.NQuadsParser()` for direct RDFLib parsing. + Note that, opposed to `jsonld.parse_nquads`, this does NOT preserve blank node + identifiers by default. + +## Compatibility helpers + +PyLD 4 includes conversion helpers for applications that need to bridge between +the old in-memory RDF.js-like nested `dict` and RDFLib: + +{{ example('legacy_helpers.py') }} + +Treat these helpers as migration aids. Prefer RDFLib terms and datasets in new +code so RDF processing is compatible with the rest of the Python RDF ecosystem. + +## Behavior fixes to expect + +The RDFLib migration also fixes several RDF conversion edge cases: + +- RDF literal lexical forms are preserved more carefully through RDFLib + conversion, including canonical double output, large numeric values, and + compound literals. +- Invalid IRI and language values are skipped during `jsonld.to_rdf()` instead + of producing invalid triples or crashing. +- Query and fragment reconstruction in `iri_resolver.unresolve()` is corrected. +- More W3C URDNA2015, URDNA2012, RDFC10, and JSON-LD `toRdf` tests run through + the default test runner. + +## Upgrade checklist + +- Add `rdflib` to application constraints if dependencies are pinned outside + PyLD's package metadata. +- Audit `jsonld.to_rdf()` call sites that do not pass `format`. Update them to + handle `rdflib.Dataset` or temporarily pass `legacyMode`. +- Remove imports of `pyld.nquads`. +- Check normalization call sites that relied on the old default algorithm. Pass + `{"algorithm": "URDNA2015"}` explicitly if that output must remain stable. +- Compare N-Quads as RDF data or sorted lines in tests unless the test requires + exact serializer ordering. diff --git a/lib/pyld/__about__.py b/lib/pyld/__about__.py index 5e8b1add..41057af6 100644 --- a/lib/pyld/__about__.py +++ b/lib/pyld/__about__.py @@ -4,4 +4,4 @@ __copyright__ = 'Copyright (c) 2011-2024 Digital Bazaar, Inc.' __license__ = 'BSD 3-Clause "New" or "Revised" License' -__version__ = '3.3.0-dev' +__version__ = '4.0.0-dev' diff --git a/lib/pyld/canon.py b/lib/pyld/canon.py index a22d85e9..f77d14b4 100644 --- a/lib/pyld/canon.py +++ b/lib/pyld/canon.py @@ -1,8 +1,15 @@ import copy import hashlib +import rdflib +from rdflib import XSD, BNode, Dataset, Literal, Node +from rdflib.graph import DATASET_DEFAULT_GRAPH_ID +from rdflib.parser import StringInputSource +from rdflib.plugins.parsers.nquads import NQuadsParser +from rdflib.plugins.serializers.nt import _quote_encode + from pyld.identifier_issuer import IdentifierIssuer -from pyld.nquads import parse_nquads, serialize_nquad +from pyld.util import from_legacy_dataset, to_legacy_dataset class URDNA2015: @@ -14,11 +21,12 @@ def __init__(self): self.blank_node_info = {} self.hash_to_blank_nodes = {} self.canonical_issuer = IdentifierIssuer('_:c14n') - self.quads = [] - self.POSITIONS = {'subject': 's', 'object': 'o', 'name': 'g'} + self.dataset = None + self.POSITIONS = ['s', 'p', 'o', 'g'] + self.hash_algorithm = hashlib.sha256 # 4.4) Normalization Algorithm - def main(self, dataset, options): + def main(self, dataset: str | dict | Dataset, options) -> str | dict: # handle invalid output format if 'format' in options and ( options['format'] != 'application/n-quads' @@ -26,32 +34,69 @@ def main(self, dataset, options): ): raise UnknownFormatError('Unknown output format.', options['format']) + # handle differtent input types: nquads string, dict (legacy ), or rdflib Dataset + rdflib_dataset = Dataset() + parser = NQuadsParser() + if isinstance(dataset, str): + # Only support N-Quads string input for now + if ( + options['inputFormat'] != 'application/n-quads' + and options['inputFormat'] != 'application/nquads' + ): + raise UnknownFormatError('Unknown input format.', options['format']) + rdflib.NORMALIZE_LITERALS = False + parser.parse(StringInputSource(dataset), rdflib_dataset) + elif isinstance(dataset, dict): + rdflib_dataset = from_legacy_dataset(dataset) + elif isinstance(dataset, Dataset): + rdflib_dataset = dataset + else: + raise ValueError(f'Unsupported dataset type: {type(dataset)}') + + normalized, bnode_map = self._canonicalize(rdflib_dataset) + + # Merge any new bnode IDs from the parser into the id_map, + # mapping old bnode IDs to their new canonical IDs + for k, v in parser._bnode_ids.items(): + bnode_id = str(v) + if bnode_id in bnode_map: + bnode_map[k] = bnode_map[bnode_id] + del bnode_map[bnode_id] + + # If outputMap option is set, return the map of blank node identifiers. + if options.get('outputMap'): + return dict(sorted(bnode_map.items(), key=lambda item: item[1])) + + # 8) Return the normalized dataset. + if ( + options.get('format') == 'application/n-quads' + or options.get('format') == 'application/nquads' + ): + return normalized + + # If the output format is not nquads, return a dataset object. + result = Dataset().parse(data=normalized, format='nquads') + return to_legacy_dataset(result) + + def _canonicalize(self, dataset: Dataset) -> tuple[str, dict]: + """ + Performs RDF Dataset Canonicalization on the given rdflib.Dataset + and returns the normalized output along with a map of blank node + identifiers to their canonical identifiers. + """ + + self.dataset = dataset # 1) Create the normalization state. # 2) For every quad in input dataset: - for graph_name, triples in dataset.items(): - if graph_name == '@default': - graph_name = None - for triple in triples: - quad = triple - if graph_name is not None: - if graph_name.startswith('_:'): - quad['name'] = {'type': 'blank node'} - else: - quad['name'] = {'type': 'IRI'} - quad['name']['value'] = graph_name - self.quads.append(quad) - - # 2.1) For each blank node that occurs in the quad, add a - # reference to the quad using the blank node identifier in the - # blank node to quads map, creating a new entry if necessary. - for key, component in quad.items(): - if key == 'predicate' or component['type'] != 'blank node': - continue - id_ = component['value'] - self.blank_node_info.setdefault(id_, {'quads': []})['quads'].append( - quad - ) + for s, p, o, g in dataset.quads((None, None, None, None)): + # 2.1) For each blank node that occurs in the quad, add a + # reference to the quad using the blank node identifier in the + # blank node to quads map, creating a new entry if necessary. + for component in (s, o, g if g else None): + if isinstance(component, BNode): + id_ = str(component) + self.blank_node_info.setdefault(id_, {'quads': []})['quads'].append((s, p, o, g)) # 3) Create a list of non-normalized blank node identifiers and # populate it using the keys from the blank node to quads map. @@ -151,33 +196,41 @@ def main(self, dataset, options): # 7) For each quad, quad, in input dataset: normalized = [] - for quad in self.quads: + id_map = {} + for s, p, o, g in self.dataset.quads((None, None, None, None)): # 7.1) Create a copy, quad copy, of quad and replace any existing # blank node identifiers using the canonical identifiers previously # issued by canonical issuer. Note: We optimize away the copy here. - for key, component in quad.items(): - if key == 'predicate': - continue - if component['type'] == 'blank node' and not component[ - 'value' - ].startswith(self.canonical_issuer.prefix): - component['value'] = self.canonical_issuer.get_id( - component['value'] - ) + + # Helper to map nodes + def map_node(node): + if isinstance(node, BNode): + node_id = str(node) + # Only issue a new ID if it's not already canonicalized + cid = self.canonical_issuer.get_id(node_id) + if cid.startswith('_:'): + cid = cid[2:] # Strip '_:' prefix for rdflib BNode compatibility + id_map[node_id] = cid + return BNode(cid) + return node + + # Transform Subject, Object, and Graph Name (Predicate is never a BNode in RDFC1.0) + s_n = map_node(s) + p_n = p # Predicates are never BNodes in standard RDF + o_n = map_node(o) + g_n = map_node(g) + + # Use modified version of rdflib's internal _nq_row for standardized string output + line = self._nq_row((s_n, p_n, o_n),g_n) # 7.2) Add quad copy to the normalized dataset. - normalized.append(serialize_nquad(quad)) + normalized.append(line) # sort normalized output normalized.sort() - # 8) Return the normalized dataset. - if ( - options.get('format') == 'application/n-quads' - or options.get('format') == 'application/nquads' - ): - return ''.join(normalized) - return parse_nquads(''.join(normalized)) + # return nquads string + return ''.join(normalized), id_map # 4.6) Hash First Degree Quads def hash_first_degree_quads(self, id_): @@ -195,23 +248,25 @@ def hash_first_degree_quads(self, id_): quads = info['quads'] # 3) For each quad quad in quads: - for quad in quads: + for s, p, o, g in quads: # 3.1) Serialize the quad in N-Quads format with the following # special rule: # 3.1.1) If any component in quad is an blank node, then serialize # it using a special identifier as follows: - copy = {} - for key, component in quad.items(): - if key == 'predicate': - copy[key] = component - continue - # 3.1.2) If the blank node's existing blank node identifier - # matches the reference blank node identifier then use the - # blank node identifier _:a, otherwise, use the blank node - # identifier _:z. - copy[key] = self.modify_first_degree_component(id_, component, key) - nquads.append(serialize_nquad(copy)) + p_n = p # Predicates are never BNodes in standard RDF + + # 3.1.2) If the blank node's existing blank node identifier + # matches the reference blank node identifier then use the + # blank node identifier _:a, otherwise, use the blank node + # Replace current BNode with _:a, others with _:z for hashing + s_n = self.modify_first_degree_component(id_, s) + o_n = self.modify_first_degree_component(id_, o) + g_n = self.modify_first_degree_component(id_, g) + + # Use rdflib's internal _nt_row for standardized string output + line = self._nq_row((s_n, p_n, o_n), g_n) + nquads.append(line) # 4) Sort nquads in lexicographical order. nquads.sort() @@ -222,12 +277,10 @@ def hash_first_degree_quads(self, id_): return info['hash'] # helper for modifying component during Hash First Degree Quads - def modify_first_degree_component(self, id_, component, key): - if component['type'] != 'blank node': + def modify_first_degree_component(self, id_: str, component: Node, key: str = None): + if not isinstance(component, BNode): return component - component = copy.deepcopy(component) - component['value'] = '_:a' if component['value'] == id_ else '_:z' - return component + return BNode("a") if str(component) == id_ else BNode("z") # 4.7) Hash Related Blank Node def hash_related_blank_node(self, related, quad, issuer, position): @@ -261,7 +314,8 @@ def hash_related_blank_node(self, related, quad, issuer, position): # helper for getting a related predicate def get_related_predicate(self, quad): - return '<' + quad['predicate']['value'] + '>' + # quad is (s, p, o, g) + return f"<{str(quad[1])}>" # 4.8) Hash N-Degree Quads def hash_n_degree_quads(self, id_, issuer): @@ -400,20 +454,18 @@ def create_hash_to_related(self, id_, issuer): # 3.1) For each component in quad, if component is the subject, # object, and graph name and it is a blank node that is not # identified by identifier: - for key, component in quad.items(): - if ( - key != 'predicate' - and component['type'] == 'blank node' - and component['value'] != id_ - ): + for i, component in enumerate(quad): + if i != 1 and isinstance(component, BNode) and str(component) != id_: # 3.1.1) Set hash to the result of the Hash Related Blank # Node algorithm, passing the blank node identifier for # component as related, quad, path identifier issuer as # issuer, and position as either s, o, or g based on # whether component is a subject, object, graph name, # respectively. - related = component['value'] - position = self.POSITIONS[key] + + related = str(component) + # correct position codes: subject='s', object='o', graph='g' + position = self.POSITIONS[i] hash = self.hash_related_blank_node(related, quad, issuer, position) # 3.1.2) Add a mapping of hash to the blank node identifier @@ -425,7 +477,7 @@ def create_hash_to_related(self, id_, issuer): # helper to create appropriate hash object def create_hash(self): - return hashlib.sha256() + return self.hash_algorithm() # helper to hash a list of nquads def hash_nquads(self, nquads): @@ -434,6 +486,32 @@ def hash_nquads(self, nquads): md.update(nquad.encode('utf8')) return md.hexdigest() + # TODO: use drop-in replacements to not serialize with xsd:string; better to solve this at the rdflib level + def _nq_row(self, triple, context): + graph_name = ( + context.n3() + " " + if context and context != DATASET_DEFAULT_GRAPH_ID + else "" + ) + if isinstance(triple[2], Literal): + return f"{triple[0].n3()} {triple[1].n3()} {self._quoteLiteral(triple[2])} {graph_name}.\n" + else: + return f"{triple[0].n3()} {triple[1].n3()} {triple[2].n3()} {graph_name}.\n" + + def _quoteLiteral(self, l_: Literal) -> str: # noqa: N802 + """A simpler version of term.Literal.n3()""" + + encoded = _quote_encode(l_) + + if l_.language: + if l_.datatype: + raise Exception("Literal has datatype AND language!") + return f"{encoded}@{l_.language}" + elif l_.datatype and l_.datatype != XSD.string: + return f"{encoded}^^<{l_.datatype}>" + else: + return f"{encoded}" + class URGNA2012(URDNA2015): """ @@ -442,21 +520,19 @@ class URGNA2012(URDNA2015): def __init__(self): URDNA2015.__init__(self) + self.hash_algorithm = hashlib.sha1 # helper for modifying component during Hash First Degree Quads - def modify_first_degree_component(self, id_, component, key): - if component['type'] != 'blank node': + def modify_first_degree_component(self, id_: str, component: Node, key: str = None): + if not isinstance(component, BNode): return component - component = copy.deepcopy(component) if key == 'name': - component['value'] = '_:g' - else: - component['value'] = '_:a' if component['value'] == id_ else '_:z' - return component + return BNode("g") + return BNode("a") if str(component) == id_ else BNode("z") # helper for getting a related predicate def get_related_predicate(self, quad): - return quad['predicate']['value'] + return str(quad[1]) # helper for creating hash to related blank nodes map def create_hash_to_related(self, id_, issuer): @@ -470,16 +546,17 @@ def create_hash_to_related(self, id_, issuer): # 3) For each quad in quads: for quad in quads: + s, p , o, g = quad # 3.1) If the quad's subject is a blank node that does not match # identifier, set hash to the result of the Hash Related Blank Node # algorithm, passing the blank node identifier for subject as # related, quad, path identifier issuer as issuer, and p as # position. if ( - quad['subject']['type'] == 'blank node' - and quad['subject']['value'] != id_ + isinstance(s, BNode) + and str(s) != id_ ): - related = quad['subject']['value'] + related = str(s) position = 'p' # 3.2) Otherwise, if quad's object is a blank node that does # not match identifier, to the result of the Hash Related Blank @@ -487,10 +564,10 @@ def create_hash_to_related(self, id_, issuer): # as related, quad, path identifier issuer as issuer, and r # as position. elif ( - quad['object']['type'] == 'blank node' - and quad['object']['value'] != id_ + isinstance(o, BNode) + and str(o) != id_ ): - related = quad['object']['value'] + related = str(o) position = 'r' # 3.3) Otherwise, continue to the next quad. else: @@ -504,57 +581,73 @@ def create_hash_to_related(self, id_, issuer): return hash_to_related - # helper to create appropriate hash object - def create_hash(self): - return hashlib.sha1() +class RDFC10(URDNA2015): + """ + RDFC10 implements the RDF Canonicalization algorithm version 1.0. + """ + + def __init__(self, hash_algorithm = None): + URDNA2015.__init__(self) + # determine hash algorithm to use + if hash_algorithm is not None: + if hash_algorithm.lower() not in hashlib.algorithms_available: + raise UnknownFormatError('Unknown hash algorithm.', hash_algorithm) + self.hash_algorithm = getattr(hashlib, hash_algorithm.lower()) + + def _quoteLiteral(self, l_: Literal) -> str: # noqa: N802 + """A simpler version of term.Literal.n3()""" + + encoded = self._quote_encode(l_) + + if l_.language: + if l_.datatype: + raise Exception("Literal has datatype AND language!") + return f"{encoded}@{l_.language}" + elif l_.datatype and l_.datatype != XSD.string: + return f"{encoded}^^<{l_.datatype}>" + else: + return f"{encoded}" + + def _quote_encode(self, l_: str) -> str: + # Accept either an rdflib Literal or a plain string + s = str(l_) + + parts = '' + for ch in s: + code = ord(ch) + if ch == "\\": + parts += '\\\\' + elif ch == '"': + parts += '\\"' + elif ch == "\n": + parts += '\\n' + elif ch == "\r": + parts += '\\r' + elif ch == "\t": + parts += '\\t' + elif code == 0x08: # backspace + parts += '\\b' + elif code == 0x0C: # form feed + parts += '\\f' + elif code == 0x0B or (code < 0x20) or (code == 0x7F): # vertical tab -> use \u000B + parts += f'\\u{code:04X}' + else: + parts += ch + + return '"' + parts + '"' def permutations(elements): """ Generates all of the possible permutations for the given list of elements. + Uses itertools.permutations on a sorted copy. :param elements: the list of elements to permutate. """ - # begin with sorted elements - elements.sort() - # initialize directional info for permutation algorithm - left = {} - for v in elements: - left[v] = True - - length = len(elements) - last = length - 1 - while True: - yield elements - - # Calculate the next permutation using the Steinhaus-Johnson-Trotter - # permutation algorithm. - - # get largest mobile element k - # (mobile: element is greater than the one it is looking at) - k, pos = None, 0 - for i in range(length): - e = elements[i] - is_left = left[e] - if (k is None or e > k) and ( - (is_left and i > 0 and e > elements[i - 1]) - or (not is_left and i < last and e > elements[i + 1]) - ): - k, pos = e, i - - # no more permutations - if k is None: - return - - # swap k and the element it is looking at - swap = pos - 1 if left[k] else pos + 1 - elements[pos], elements[swap] = elements[swap], k - - # reverse the direction of all elements larger than k - for i in range(length): - if elements[i] > k: - left[elements[i]] = not left[elements[i]] - + from itertools import permutations as _it_permutations + els = sorted(elements) + for perm in _it_permutations(els): + yield list(perm) class UnknownFormatError(ValueError): """ diff --git a/lib/pyld/iri_resolver.py b/lib/pyld/iri_resolver.py index 0535e966..2090a68f 100644 --- a/lib/pyld/iri_resolver.py +++ b/lib/pyld/iri_resolver.py @@ -127,14 +127,14 @@ def remove_dot_segments_of_path(iri: str, colon_position: int) -> str: def resolve(relative_iri: str, base_iri: str = None) -> str: - # """ - # Resolves a given relative IRI to an absolute IRI. + """ + Resolves a given relative IRI to an absolute IRI. - # :param base_iri: the base IRI. - # :param relative_iri: the relative IRI. + :param base_iri: the base IRI. + :param relative_iri: the relative IRI. - # :return: the absolute IRI. - # """ + :return: the absolute IRI. + """ base_iri = base_iri or '' base_fragment_pos = base_iri.find("#") @@ -293,7 +293,7 @@ def unresolve(absolute_iri: str, base_iri: str = ""): rval = './' + rval # build relative IRI using urlunparse with empty scheme/netloc - return urlunparse(('', '', rval, '', rel.query or '', rel.fragment or '')) or './' + return urlunparse(('', '', rval, '', rel.query, rel.fragment)) or './' def parse_authority(parsed_iri: ParseResult) -> str: diff --git a/lib/pyld/jsonld.py b/lib/pyld/jsonld.py index db7374c2..9d88260b 100644 --- a/lib/pyld/jsonld.py +++ b/lib/pyld/jsonld.py @@ -31,21 +31,19 @@ import lxml.html from cachetools import LRUCache from frozendict import frozendict +from rdflib import RDF, XSD, BNode, Dataset, Literal, Node, URIRef +from rdflib.graph import DATASET_DEFAULT_GRAPH_ID +from rdflib.parser import StringInputSource +from rdflib.plugins.serializers.nquads import _nq_row +from rdflib.term import Identifier, _is_valid_uri from c14n.Canonicalize import canonicalize from pyld.__about__ import __copyright__, __license__, __version__ -from pyld.canon import URDNA2015, URGNA2012, UnknownFormatError +from pyld.canon import RDFC10, URDNA2015, URGNA2012, UnknownFormatError +from pyld.context_resolver import ContextResolver from pyld.identifier_issuer import IdentifierIssuer -from pyld.nquads import ( - ParserError, - parse_nquads, - serialize_nquad, - serialize_nquads, -) - -from .context_resolver import ContextResolver -from .iri_resolver import resolve, unresolve -from .options import ( +from pyld.iri_resolver import resolve, unresolve +from pyld.options import ( CompactOptions, Context, ExpandOptions, @@ -55,6 +53,12 @@ NormalizeOptions, ToRdfOptions, ) +from pyld.util import ( + UnnormalizedNQuadsParser, + from_legacy_dataset, + from_legacy_triple, + to_legacy_dataset, +) __all__ = [ '__copyright__', @@ -90,24 +94,6 @@ 'freeze', ] -# XSD constants -XSD_BOOLEAN = 'http://www.w3.org/2001/XMLSchema#boolean' -XSD_DOUBLE = 'http://www.w3.org/2001/XMLSchema#double' -XSD_INTEGER = 'http://www.w3.org/2001/XMLSchema#integer' -XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string' - -# RDF constants -RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' -RDF_LIST = RDF + 'List' -RDF_FIRST = RDF + 'first' -RDF_REST = RDF + 'rest' -RDF_NIL = RDF + 'nil' -RDF_TYPE = RDF + 'type' -RDF_LANGSTRING = RDF + 'langString' -RDF_JSON_LITERAL = RDF + 'JSON' -RDF_VALUE = RDF + 'value' -RDF_LANGUAGE = RDF + 'language' -RDF_DIRECTION = RDF + 'direction' # BCP47 REGEX_BCP47 = r'^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$' @@ -885,8 +871,10 @@ def normalize(self, input_, options): :param input_: the JSON-LD input to normalize. :param options: the options to use. - [algorithm] the algorithm to use: `URDNA2015` or `URGNA2012` - (default: `URGNA2012`). + [algorithm] the algorithm to use: `RDFC10`, `URDNA2015` or `URGNA2012` + (default: `RDFC10`). + [hashAlgorithm] the hashing algorithm to use; only applicable to `RDFC10`. + (default: `SHA256`). [base] the base IRI to use. [contextResolver] internal use only. [inputFormat] the format if input is not JSON-LD: @@ -895,12 +883,15 @@ def normalize(self, input_, options): 'application/n-quads' for N-Quads. [documentLoader(url, options)] the document loader (default: _default_document_loader). + [outputMap] if True, the function will return a map of blank node + identifiers to their normalized identifiers instead of the + normalized dataset (default: False). - :return: the normalized output. + :return: the normalized output or the map of blank node identifiers. """ # set default options options = options.copy() if options else {} - options.setdefault('algorithm', 'URGNA2012') + options.setdefault('algorithm', 'RDFC10') options.setdefault('base', input_ if _is_string(input_) else '') options.setdefault('documentLoader', _default_document_loader) options.setdefault( @@ -912,21 +903,14 @@ def normalize(self, input_, options): # Prevent a custom identifier issuer accidentally messing up normalization. options.pop('identifierIssuer', None) - if options['algorithm'] not in ['URDNA2015', 'URGNA2012']: + if options['algorithm'] not in ['RDFC10', 'URDNA2015', 'URGNA2012']: raise JsonLdError( 'Unsupported normalization algorithm.', 'jsonld.NormalizeError' ) try: if 'inputFormat' in options: - if ( - options['inputFormat'] != 'application/n-quads' - and options['inputFormat'] != 'application/nquads' - ): - raise JsonLdError( - 'Unknown normalization input format.', 'jsonld.NormalizeError' - ) - dataset = JsonLdProcessor.parse_nquads(input_) + dataset = input_ else: # convert to RDF dataset then do normalization opts = dict(options) @@ -941,18 +925,24 @@ def normalize(self, input_, options): ) from cause # do normalization - if options['algorithm'] == 'URDNA2015': - try: - return URDNA2015().main(dataset, options) - except UnknownFormatError as cause: - raise JsonLdError( - str(cause), 'jsonld.UnknownFormat', {'format': cause.format} - ) from cause - + if options['algorithm'] == 'RDFC10': + algorithm = RDFC10(hash_algorithm = options.get('hashAlgorithm')) + elif options['algorithm'] == 'URDNA2015': + algorithm = URDNA2015() # assume URGNA2012 - return URGNA2012().main(dataset, options) + else: + algorithm = URGNA2012() - def from_rdf(self, dataset, options): + try: + return algorithm.main(dataset, options) + except UnknownFormatError as cause: + raise JsonLdError( + str(cause), + 'jsonld.UnknownFormat', + {'format': cause.format}) from cause + + + def from_rdf(self, dataset: dict | Dataset | str, options): """ Converts an RDF dataset to JSON-LD. @@ -1000,6 +990,10 @@ def from_rdf(self, dataset, options): parser = _rdf_parsers[options['format']] dataset = parser(dataset) + # Convert legacy datasets for backwards-compatibility + if not isinstance(dataset, Dataset): + dataset = from_legacy_dataset(dataset) + # convert from RDF return self._from_rdf(dataset, options) @@ -1021,6 +1015,7 @@ def to_rdf(self, input_, options): identifiers (default: IdentifierIssuer('_:b')). [rdfDirection] Either 'i18n-datatype' or 'compound-literal' (default: None). + [legacyMode] Output result as RDF.js-like dict. :return: the resulting RDF dataset (or a serialization of it). """ @@ -1050,25 +1045,29 @@ def to_rdf(self, input_, options): node_map = {'@default': {}} self._create_node_map(expanded, node_map, '@default', issuer) # output RDF dataset - dataset = {} + dataset = Dataset() for graph_name, graph in sorted(node_map.items()): # skip relative IRIs - if graph_name == '@default' or _is_absolute_iri(graph_name): - dataset[graph_name] = self._graph_to_rdf(graph, issuer, options) - + if graph_name == '@default' or _is_valid_absolute_iri(graph_name): + g = self._rdflib_term_from_id(graph_name) if graph_name != '@default' else dataset.default_graph + for t in self._graph_to_rdf(graph, issuer, options): + s, p, o = t + dataset.add((s, p, o, g)) # convert to output format if 'format' in options: if ( options['format'] == 'application/n-quads' or options['format'] == 'application/nquads' ): - return self.to_nquads(dataset) + return dataset.serialize(format='nquads') raise JsonLdError( 'Unknown output format.', 'jsonld.UnknownFormat', {'format': options['format']}, ) - return dataset + # In legacy mode, return RDF datasets as + # RDFJS-like dict objects equivalent to PyLD < 4.0 + return to_legacy_dataset(dataset) if options.get('legacyMode', False) else dataset def process_context(self, active_ctx, local_ctx, options): """ @@ -1275,12 +1274,9 @@ def remove_value(subject, property, value, options=None): options = {} options.setdefault('propertyIsArray', False) - # filter out value - def filter_value(e): - return not JsonLdProcessor.compare_values(e, value) - values = JsonLdProcessor.get_values(subject, property) - values = list(filter(filter_value, values)) + # filter out value + values = [e for e in values if not JsonLdProcessor.compare_values(e, value)] if len(values) == 0: JsonLdProcessor.remove_property(subject, property) @@ -1381,24 +1377,42 @@ def get_context_value(ctx, key, type_): return rval @staticmethod - def parse_nquads(input_): + def parse_nquads(input_: str, **kwargs) -> Dataset | dict: """ Parses RDF in the form of N-Quads. :param input_: the N-Quads input to parse. + :param **kwargs: Additional keyword arguments. + + :Keyword Arguments: + * *legacy_mode* (``bool``) -- Output result as RDF.js-like dict. :return: an RDF dataset. """ try: - result = parse_nquads(input_) - return result - except ParserError as cause: + dataset = Dataset() + # TODO: workaround to preserve bnodes for testing and preserve previous behaviour; + # this should be handled in the test harness instead + bnode_context = { + label: label + for label in re.findall(r'_:([A-Za-z0-9_][A-Za-z0-9_.-]*)', input_) + } + parser = UnnormalizedNQuadsParser() + parser.parse(StringInputSource(input_), dataset, bnode_context=bnode_context) + # In legacy mode, return RDF datasets as + # RDFJS-like dict objects equivalent to PyLD < 4.0 + return to_legacy_dataset(dataset) if kwargs.get('legacy_mode', False) else dataset + except SyntaxError as cause: raise JsonLdError( - str(cause), 'jsonld.ParseError', {'line': cause.line_number} + str(cause), 'jsonld.ParseError', {'line': cause.lineno} + ) from cause + except Exception as cause: + raise JsonLdError( + str(cause), 'jsonld.ParseError', {} ) from cause @staticmethod - def to_nquads(dataset): + def to_nquads(dataset: dict | Dataset): """ Converts an RDF dataset to N-Quads. @@ -1406,11 +1420,21 @@ def to_nquads(dataset): :return: the N-Quads string. """ - return serialize_nquads(dataset) + if not isinstance(dataset, Dataset): + dataset = from_legacy_dataset(dataset) + return dataset.serialize(format='nquads') @staticmethod - def to_nquad(triple, graph_name=None): - return serialize_nquad(triple, graph_name) + def to_nquad(triple: dict | tuple[Node, Node, Node], graph_name=None): + """Converts an RDF triple to an N-Quad string. + :param triple: the RDF triple to convert, either as a dict with keys + 'subject', 'predicate', 'object' (legacy) or as a tuple of rdflib Nodes. + :param graph_name: the name of the graph, if any (default: None). + :return: the N-Quad string representation of the triple. + """ + if isinstance(triple, dict): + triple = from_legacy_triple(triple) + return _nq_row(triple, graph_name) @staticmethod def arrayify(value): @@ -1631,7 +1655,7 @@ def _compact(self, active_ctx, active_property, element, options): ) del compacted_value[compacted_property] - if len(compacted_value.keys()) > 0: + if compacted_value: # use keyword alias and add value alias = self._compact_iri(active_ctx, expanded_property) JsonLdProcessor.add_value(rval, alias, compacted_value) @@ -1948,7 +1972,7 @@ def _compact(self, active_ctx, active_property, element, options): # whose key maps to @id, recompact without @type if ( _is_object(compacted_item) - and len(compacted_item.keys()) == 1 + and len(compacted_item) == 1 and '@id' in expanded_item ): compacted_item = self._compact( @@ -2931,7 +2955,7 @@ def _frame(self, input_, frame, options): return framed - def _from_rdf(self, dataset, options): + def _from_rdf(self, dataset: Dataset, options): """ Converts an RDF dataset to JSON-LD. @@ -2944,59 +2968,62 @@ def _from_rdf(self, dataset, options): graph_map = {'@default': default_graph} referenced_once = {} - for name, graph in dataset.items(): + for s, p, o, graph_name in dataset.quads((None, None, None, None)): + name = ( + '@default' + if graph_name is None or graph_name == DATASET_DEFAULT_GRAPH_ID + else self._id_from_rdflib_term(graph_name) + ) graph_map.setdefault(name, {}) if name != '@default' and name not in default_graph: default_graph[name] = {'@id': name} node_map = graph_map[name] - for triple in graph: - # get subject, predicate, object - s = triple['subject']['value'] - p = triple['predicate']['value'] - o = triple['object'] - - node = node_map.setdefault(s, {'@id': s}) + s = self._id_from_rdflib_term(s) + p = str(p) - object_is_id = o['type'] == 'IRI' or o['type'] == 'blank node' - if object_is_id and o['value'] not in node_map: - node_map[o['value']] = {'@id': o['value']} + node = node_map.setdefault(s, {'@id': s}) - if ( - p == RDF_TYPE - and not options.get('useRdfType', False) - and object_is_id - ): - JsonLdProcessor.add_value( - node, '@type', o['value'], {'propertyIsArray': True} - ) - continue + object_is_id = not isinstance(o, Literal) + object_id = self._id_from_rdflib_term(o) if object_is_id else None + if object_is_id and object_id not in node_map: + node_map[object_id] = {'@id': object_id} - value = self._rdf_to_object( - o, options['useNativeTypes'], options['rdfDirection'] + if ( + p == str(RDF.type) + and not options.get('useRdfType', False) + and object_is_id + ): + JsonLdProcessor.add_value( + node, '@type', object_id, {'propertyIsArray': True} ) - JsonLdProcessor.add_value(node, p, value, {'propertyIsArray': True}) - - # object may be an RDF list/partial list node but we - # can't know easily until all triples are read - if object_is_id: - # track rdf:nil uniquely per graph - if o['value'] == RDF_NIL: - object = node_map[o['value']] - if 'usages' not in object: - object['usages'] = [] - object['usages'].append( - {'node': node, 'property': p, 'value': value} - ) - # object referenced more than once - elif o['value'] in referenced_once: - referenced_once[o['value']] = False - # track single reference - else: - referenced_once[o['value']] = { - 'node': node, - 'property': p, - 'value': value, - } + continue + + value = self._rdf_to_object( + o, options['useNativeTypes'], options['rdfDirection'] + ) + JsonLdProcessor.add_value(node, p, value, {'propertyIsArray': True}) + + # object may be an RDF list/partial list node but we + # can't know easily until all triples are read + if object_is_id: + # track rdf:nil uniquely per graph + if object_id == str(RDF.nil): + object = node_map[object_id] + if 'usages' not in object: + object['usages'] = [] + object['usages'].append( + {'node': node, 'property': p, 'value': value} + ) + # object referenced more than once + elif object_id in referenced_once: + referenced_once[object_id] = False + # track single reference + else: + referenced_once[object_id] = { + 'node': node, + 'property': p, + 'value': value, + } for _name, graph_object in graph_map.items(): # Convert compound-literal blank nodes before list conversion, so @@ -3007,11 +3034,11 @@ def _from_rdf(self, dataset, options): # convert linked lists to @list arrays # no @lists to be converted, continue - if RDF_NIL not in graph_object: + if str(RDF.nil) not in graph_object: continue # iterate backwards through each RDF list - nil = graph_object[RDF_NIL] + nil = graph_object[str(RDF.nil)] if 'usages' not in nil: continue for usage in nil['usages']: @@ -3027,25 +3054,25 @@ def _from_rdf(self, dataset, options): # 3. Have an array for rdf:rest that has 1 item # 4. Have no keys other than: @id, rdf:first, rdf:rest # and, optionally, @type where the value is rdf:List. - node_key_count = len(node.keys()) + node_key_count = len(node) while ( - property == RDF_REST + property == str(RDF.rest) and _is_object(referenced_once.get(node['@id'])) - and _is_array(node[RDF_FIRST]) - and len(node[RDF_FIRST]) == 1 - and _is_array(node[RDF_REST]) - and len(node[RDF_REST]) == 1 + and _is_array(node[str(RDF.first)]) + and len(node[str(RDF.first)]) == 1 + and _is_array(node[str(RDF.rest)]) + and len(node[str(RDF.rest)]) == 1 and ( node_key_count == 3 or ( node_key_count == 4 and _is_array(node.get('@type')) and len(node['@type']) == 1 - and node['@type'][0] == RDF_LIST + and node['@type'][0] == str(RDF.List) ) ) ): - list_.append(node[RDF_FIRST][0]) + list_.append(node[str(RDF.first)][0]) list_nodes.append(node['@id']) # get next node, moving backwards through list @@ -3053,7 +3080,7 @@ def _from_rdf(self, dataset, options): node = usage['node'] property = usage['property'] head = usage['value'] - node_key_count = len(node.keys()) + node_key_count = len(node) # if node is not a blank node, then list head found if not node['@id'].startswith('_:'): @@ -3141,26 +3168,31 @@ def _compound_literal_to_value(self, id_, node): rdf:direction, and optionally rdf:language. Each RDF property must have exactly one JSON-LD value-object entry. """ - allowed_keys = {RDF_VALUE, RDF_LANGUAGE, RDF_DIRECTION, '@id'} + + rdf_value = str(RDF.value) + rdf_language= str(RDF.language) + rdf_direction= str(RDF.direction) + + allowed_keys = {rdf_value, rdf_language, rdf_direction, '@id'} # Anything with extra properties is an ordinary node, not the special # compound-literal encoding. if ( not id_.startswith('_:') or set(node.keys()) - allowed_keys - or RDF_VALUE not in node - or RDF_DIRECTION not in node + or rdf_value not in node + or rdf_direction not in node ): return None - if not self._is_single_rdf_value(node, RDF_VALUE): + if not self._is_single_rdf_value(node, rdf_value): raise JsonLdError( 'Invalid JSON-LD syntax; rdf:value must be a single value.', 'jsonld.SyntaxError', code='invalid value object', ) - if not self._is_single_rdf_value(node, RDF_DIRECTION): + if not self._is_single_rdf_value(node, rdf_direction): raise JsonLdError( 'Invalid JSON-LD syntax; rdf:direction must be a single value.', 'jsonld.InvalidBaseDirection', @@ -3169,8 +3201,8 @@ def _compound_literal_to_value(self, id_, node): # Start from rdf:value so datatype/native literal handling already # done by _rdf_to_object is preserved. - value = node[RDF_VALUE][0].copy() - direction = node[RDF_DIRECTION][0].get('@value') + value = node[rdf_value][0].copy() + direction = node[rdf_direction][0].get('@value') if direction not in ['ltr', 'rtl']: raise JsonLdError( 'Invalid JSON-LD syntax; @direction must be "ltr" or "rtl".', @@ -3178,14 +3210,14 @@ def _compound_literal_to_value(self, id_, node): code='invalid base direction', ) - if RDF_LANGUAGE in node: - if not self._is_single_rdf_value(node, RDF_LANGUAGE): + if rdf_language in node: + if not self._is_single_rdf_value(node, rdf_language): raise JsonLdError( 'Invalid JSON-LD syntax; rdf:language must be a single value.', 'jsonld.InvalidLanguageTaggedString', code='invalid language-tagged string', ) - language = node[RDF_LANGUAGE][0].get('@value') + language = node[rdf_language][0].get('@value') if not _is_string(language) or not re.match(REGEX_BCP47, language): raise JsonLdError( 'Invalid JSON-LD syntax; rdf:language must be a valid BCP47 language.', @@ -3840,53 +3872,39 @@ def _graph_to_rdf(self, graph, issuer, options): :return: the array of RDF triples for the given graph. """ - triples = [] + triples = [] # TODO: use rdflib.Graph instead for id_, node in sorted(graph.items()): for property, items in sorted(node.items()): - if property == '@type': - property = RDF_TYPE - elif _is_keyword(property): + if property != '@type' and _is_keyword(property): continue for item in items: # skip relative IRI subjects and predicates - if not (_is_absolute_iri(id_) and _is_absolute_iri(property)): + if not _is_valid_absolute_iri(id_) or ( + property != '@type' and not _is_valid_absolute_iri(property) + ): continue # RDF subject - subject = {} - if id_.startswith('_:'): - subject['type'] = 'blank node' - else: - subject['type'] = 'IRI' - subject['value'] = id_ + subject = self._rdflib_term_from_id(id_) # RDF predicate - predicate = {} - if property.startswith('_:'): - # skip bnode predicates unless producing - # generalized RDF - if not options['produceGeneralizedRdf']: - continue - predicate['type'] = 'blank node' - else: - predicate['type'] = 'IRI' - predicate['value'] = property + + # skip bnode predicates unless producing + # generalized RDF + if property.startswith('_:') and not options['produceGeneralizedRdf']: + continue + + predicate = self._rdflib_term_from_id(property) if property != '@type' else RDF.type # convert list, value or node object to triple object = self._object_to_rdf(item, issuer, triples, options) # skip None objects (they are relative IRIs) if object is not None: - triples.append( - { - 'subject': subject, - 'predicate': predicate, - 'object': object, - } - ) + triples.append((subject, predicate, object)) return triples - def _list_to_rdf(self, list_, issuer, triples, options): + def _list_to_rdf(self, list_: list, issuer: IdentifierIssuer, triples, options) -> BNode | URIRef: """ Converts a @list value into a linked list of blank node RDF triples (and RDF collection). @@ -3898,31 +3916,49 @@ def _list_to_rdf(self, list_, issuer, triples, options): :return: the head of the list """ - first = {'type': 'IRI', 'value': RDF_FIRST} - rest = {'type': 'IRI', 'value': RDF_REST} - nil = {'type': 'IRI', 'value': RDF_NIL} last = list_.pop() if list_ else None # result is the head of the list - result = {'type': 'blank node', 'value': issuer.get_id()} if last else nil + result = self._rdflib_term_from_id(issuer.get_id()) if last else RDF.nil subject = result for item in list_: object = self._object_to_rdf(item, issuer, triples, options) - next = {'type': 'blank node', 'value': issuer.get_id()} - triples.append({'subject': subject, 'predicate': first, 'object': object}) - triples.append({'subject': subject, 'predicate': rest, 'object': next}) + next = self._rdflib_term_from_id(issuer.get_id()) + if object is not None: + triples.append((subject, RDF.first, object)) + triples.append((subject, RDF.rest, next)) subject = next # tail of list if last: object = self._object_to_rdf(last, issuer, triples, options) - triples.append({'subject': subject, 'predicate': first, 'object': object}) - triples.append({'subject': subject, 'predicate': rest, 'object': nil}) + if object is not None: + triples.append((subject, RDF.first, object)) + triples.append((subject, RDF.rest, RDF.nil)) return result + + def _rdflib_term_from_id(self, id_: str) -> BNode | URIRef: + """ + Converts a JSON-LD @id value to an RDFLib term. + :param id_: the JSON-LD @id value. + :return: the RDFLib term. + """ + if id_.startswith('_:'): + return BNode(id_[2:]) + return URIRef(id_) + + def _id_from_rdflib_term(self, term: Identifier) -> str: + """ + Converts an RDFLib term to a JSON-LD @id value. + :param term: the RDFLib term. + :return: the JSON-LD @id value. + """ + return '_:' + str(term) if isinstance(term, BNode) else str(term) + def _object_to_rdf(self, item, issuer, triples, options): """ Converts a JSON-LD value object to an RDF literal or a JSON-LD string @@ -3935,124 +3971,125 @@ def _object_to_rdf(self, item, issuer, triples, options): :return: the RDF literal or RDF resource. """ - object = {} if _is_value(item): - object['type'] = 'literal' value = item['@value'] datatype = item.get('@type') rdf_direction = options.get('rdfDirection') + if '@language' in item and not re.match(REGEX_BCP47, item['@language']): + return None + # convert to XSD datatypes as appropriate if datatype == '@json': - object['value'] = canonicalize(value).decode('UTF-8') - object['datatype'] = RDF_JSON_LITERAL + return Literal( + canonicalize(value).decode('UTF-8'), datatype=RDF.JSON + ) elif _is_bool(value): - object['value'] = 'true' if value else 'false' - object['datatype'] = datatype or XSD_BOOLEAN + return Literal( + 'true' if value else 'false', + datatype=URIRef(datatype) if datatype else XSD.boolean, + ) # if `value` is a float number, elif _is_double(value): - # use the canonical double representation - object['value'] = _canonicalize_double(value) - # add the double datatype if none is given - object['datatype'] = datatype or XSD_DOUBLE - return object - elif datatype == XSD_DOUBLE: + return Literal( + # use the canonical double representation + _canonicalize_double(value), + # add the double datatype if none is given + datatype=URIRef(datatype) if datatype else XSD.double, + normalize=False, + ) + elif datatype == str(XSD.double): # since the previous branch did not activate, we know that `value` is not a float number. try: float_value = float(value) except (ValueError, TypeError): # if `value` is not convertible to float, we will return it as-is. - object['value'] = value - object['datatype'] = XSD_DOUBLE - return object + return Literal(value, datatype=XSD.double, normalize=False) else: # we have a float, and canonicalization may proceed. - object['value'] = _canonicalize_double(float_value) - object['datatype'] = XSD_DOUBLE - return object + return Literal( + _canonicalize_double(float_value), + datatype=XSD.double, + normalize=False, + ) elif ( options['processingMode'] == 'json-ld-1.1' and _is_integer(value) and abs(value) >= 1e21 ): - object['value'] = _canonicalize_double(value) - object['datatype'] = datatype or XSD_DOUBLE + return Literal( + # use the canonical double representation + _canonicalize_double(value), + # add the double datatype if none is given + datatype=URIRef(datatype) if datatype else XSD.double, + normalize=False, + ) elif _is_integer(value): - object['value'] = str(int(value)) - object['datatype'] = datatype or XSD_INTEGER + return Literal( + str(int(value)), + datatype=datatype if datatype else XSD.integer, + normalize=False, + ) elif rdf_direction == 'compound-literal' and '@direction' in item: - object['type'] = 'blank node' - object['value'] = issuer.get_id() - subject = {'type': 'blank node', 'value': object['value']} + # Create bnode to describe compound literal + subject = self._rdflib_term_from_id(issuer.get_id()) triples.append( - { - 'subject': subject, - 'predicate': {'type': 'IRI', 'value': RDF_VALUE}, - 'object': { - 'type': 'literal', - 'value': value, - 'datatype': XSD_STRING, - }, - } + (subject, RDF.value, Literal(value, datatype=XSD.string)) ) triples.append( - { - 'subject': subject, - 'predicate': {'type': 'IRI', 'value': RDF_DIRECTION}, - 'object': { - 'type': 'literal', - 'value': item['@direction'], - 'datatype': XSD_STRING, - }, - } + ( + subject, + RDF.direction, + Literal(item['@direction'], datatype=XSD.string, + normalize=False), + ) ) if '@language' in item: triples.append( - { - 'subject': subject, - 'predicate': {'type': 'IRI', 'value': RDF_LANGUAGE}, - 'object': { - 'type': 'literal', - 'value': item['@language'], - 'datatype': XSD_STRING, - }, - } + ( + subject, + RDF.language, + Literal(item['@language'], datatype=XSD.string, normalize=False), + ) ) + return subject elif rdf_direction == 'i18n-datatype' and '@direction' in item: - datatype = 'https://www.w3.org/ns/i18n#{}_{}'.format( - item.get('@language', ''), item['@direction'] + return Literal( + value, + datatype='https://www.w3.org/ns/i18n#{}_{}'.format( + item.get('@language', ''), item['@direction'] + ), + normalize=False, ) - object['value'] = value - object['datatype'] = datatype + elif '@language' in item: - object['value'] = value - object['datatype'] = datatype or RDF_LANGSTRING - object['language'] = item['@language'] - else: - object['value'] = value - object['datatype'] = datatype or XSD_STRING + return Literal( + value, + lang=item['@language'], + datatype=None, + normalize=False, + ) + return Literal( + value, + datatype=URIRef(datatype) if datatype else XSD.string, + normalize=False, + ) # convert list object to RDF elif _is_list(item): - list_ = self._list_to_rdf(item['@list'], issuer, triples, options) - object['value'] = list_['value'] - object['type'] = list_['type'] + return self._list_to_rdf(item['@list'], issuer, triples, options) + # convert string/node object to RDF else: id_ = item['@id'] if _is_object(item) else item - if id_.startswith('_:'): - object['type'] = 'blank node' - else: - object['type'] = 'IRI' - object['value'] = id_ # skip relative IRIs - if object['type'] == 'IRI' and not _is_absolute_iri(object['value']): + if not id_.startswith('_:') and not _is_valid_absolute_iri(id_): return None - return object + return self._rdflib_term_from_id(id_) - def _rdf_to_object(self, o, use_native_types, rdf_direction): + def _rdf_to_object(self, o: Identifier, use_native_types, rdf_direction) -> dict: """ Converts an RDF triple object to a JSON-LD object. @@ -4063,22 +4100,21 @@ def _rdf_to_object(self, o, use_native_types, rdf_direction): :return: the JSON-LD object. """ # convert IRI/BlankNode object to JSON-LD - if o['type'] == 'IRI' or o['type'] == 'blank node': - return {'@id': o['value']} + if not isinstance(o, Literal): + id_ = self._id_from_rdflib_term(o) + return {'@id': id_} # convert literal object to JSON-LD - rval = {'@value': o['value']} + rval = {'@value': str(o)} # add language - if 'language' in o: - rval['@language'] = o['language'] + if o.language is not None: + rval['@language'] = o.language # add datatype else: - type_ = o['datatype'] - if not type_: - type_ = XSD_STRING + type_ = o.datatype if o.datatype else XSD.string - if type_ == RDF_JSON_LITERAL: + if type_ == RDF.JSON: type_ = '@json' try: rval['@value'] = json.loads(rval['@value']) @@ -4093,24 +4129,24 @@ def _rdf_to_object(self, o, use_native_types, rdf_direction): # use native types for certain xsd types if use_native_types: converted = False - if type_ == XSD_BOOLEAN: + if type_ == XSD.boolean: if rval['@value'] in ['true', '1']: rval['@value'] = True converted = True elif rval['@value'] in ['false', '0']: rval['@value'] = False converted = True - elif type_ == XSD_INTEGER and re.match(r'^[+-]?\d+$', rval['@value']): + elif type_ == XSD.integer and re.match(r'^[+-]?\d+$', rval['@value']): rval['@value'] = int(rval['@value']) converted = True - elif type_ == XSD_DOUBLE and _is_numeric(rval['@value']): + elif type_ == XSD.double and _is_numeric(rval['@value']): value = float(rval['@value']) if math.isfinite(value): rval['@value'] = value converted = True # do not add native type - if not converted and type_ != XSD_STRING: - rval['@type'] = type_ + if not converted and type_ != XSD.string: + rval['@type'] = str(type_) elif rdf_direction == 'i18n-datatype' and type_.startswith( 'https://www.w3.org/ns/i18n#' ): @@ -4120,8 +4156,8 @@ def _rdf_to_object(self, o, use_native_types, rdf_direction): if not re.match(REGEX_BCP47, language): warnings.warn('@language must be valid BCP47', stacklevel=2) rval['@direction'] = direction - elif type_ != XSD_STRING: - rval['@type'] = type_ + elif type_ != XSD.string: + rval['@type'] = str(type_) return rval def _create_node_map( @@ -4218,6 +4254,10 @@ def _create_node_map( id_ = input_.get('@id') if _is_bnode(input_): id_ = issuer.get_id(id_) + # drop explicitly ignored @id: None subjects before they enter the node map, + # while leaving normal blank nodes without @id alone. + elif id_ is None and '@id' in input_: + return # create new subject or merge into existing one node = graph_map.setdefault(active_graph, {}).setdefault(id_, {'@id': id_}) @@ -4917,11 +4957,7 @@ def _remove_embed(self, state, id_): def remove_dependents(id_): # get embed keys as a separate array to enable deleting keys # in map - try: - ids = list(embeds.iterkeys()) - except AttributeError: - ids = list(embeds.keys()) - for next in ids: + for next in list(embeds): if ( next in embeds and _is_object(embeds[next]['parent']) @@ -6502,7 +6538,8 @@ def _is_graph(v): return ( _is_object(v) and '@graph' in v - and len([k for k, vv in v.items() if (k != '@id' and k != '@index')]) == 1 + # count non-@id/@index keys + and set(v) <= {'@graph', '@id', '@index'} ) @@ -6549,6 +6586,19 @@ def _is_absolute_iri(v): return _is_string(v) and re.match(r'^([A-Za-z][A-Za-z0-9+-.]*|_):[^\s]*$', v) +def _is_valid_absolute_iri(v): + """ + Returns True if the given value is an absolute IRI that RDFLib can + serialize, False if not. + + :param v: the value to check. + + :return: True if the value is a valid absolute IRI, False if not. + """ + # TODO: _is_valid_uri is flawed, so maybe replace it with something better + return _is_absolute_iri(v) and v.count('#') <= 1 and _is_valid_uri(v) + + def _is_relative_iri(v): """ Returns true if the given value is a relative IRI, false if not. diff --git a/lib/pyld/nquads.py b/lib/pyld/nquads.py deleted file mode 100644 index 76d20fd0..00000000 --- a/lib/pyld/nquads.py +++ /dev/null @@ -1,245 +0,0 @@ -import re - -XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string' -RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' -RDF_LANGSTRING = RDF + 'langString' - - -def escape(value: str): - return ( - value.replace("\\", "\\\\") - .replace("\t", "\\t") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace('"', '\\"') - ) - - -def unescape(value: str): - return ( - value.replace('\\"', '"') - .replace("\\t", "\t") - .replace("\\n", "\n") - .replace("\\r", "\r") - .replace("\\\\", "\\") - ) - - -def parse_nquads(input_: str): - """ - Parses RDF in the form of N-Quads. - - :param input_: the N-Quads input to parse. - - :return: an RDF dataset. - """ - # define partial regexes - iri = '(?:<([^:]+:[^>]*)>)' - bnode = '(_:(?:[A-Za-z0-9_][A-Za-z0-9_.-]*))' - plain = '"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"' - datatype = '(?:\\^\\^' + iri + ')' - language = '(?:@([a-zA-Z]+(?:-[a-zA-Z0-9]+)*))' - literal = '(?:' + plain + '(?:' + datatype + '|' + language + ')?)' - ws = '[ \\t]+' - wso = '[ \\t]*' - comment = '^#.*$' - empty = '(' + r'^' + wso + '$' + '|' + comment + ')' - - # define quad part regexes - subject = '(?:' + iri + '|' + bnode + ')' + ws - property = iri + ws - object = '(?:' + iri + '|' + bnode + '|' + literal + ')' + wso - graph = '(?:\\.|(?:(?:' + iri + '|' + bnode + ')' + wso + '\\.))' - - # Note: Notice that the graph position does not include literals - # even though they are specified as a possible value in the - # N-Quads note (http://sw.deri.org/2008/07/n-quads/). This is - # intentional, as literals in that position are not supported by the - # RDF data model or the JSON-LD data model. - # See: https://github.com/digitalbazaar/pyld/pull/19 - - # full quad regex - quad = r'^' + wso + subject + property + object + graph + wso + '$' - - # build RDF dataset - dataset = {} - - # split N-Quad input into lines - lines = input_.splitlines(True) - line_number = 0 - for line in lines: - line_number += 1 - - # skip empty lines - if re.search(empty, line) is not None: - continue - - # parse quad - match = re.search(quad, line) - if match is None: - raise ParserError( - f'Error while parsing N-Quads invalid quad {line} at line {line_number}.', - line_number=line_number, - ) - match = match.groups() - - # create RDF triple - triple = {'subject': {}, 'predicate': {}, 'object': {}} - - # get subject - if match[0] is not None: - triple['subject'] = {'type': 'IRI', 'value': match[0]} - else: - triple['subject'] = {'type': 'blank node', 'value': match[1]} - - # get predicate - triple['predicate'] = {'type': 'IRI', 'value': match[2]} - - # get object - if match[3] is not None: - triple['object'] = {'type': 'IRI', 'value': match[3]} - elif match[4] is not None: - triple['object'] = {'type': 'blank node', 'value': match[4]} - else: - triple['object'] = {'type': 'literal'} - unescaped = unescape(match[5]) - if match[6] is not None: - triple['object']['datatype'] = match[6] - elif match[7] is not None: - triple['object']['datatype'] = RDF_LANGSTRING - triple['object']['language'] = match[7] - else: - triple['object']['datatype'] = XSD_STRING - triple['object']['value'] = unescaped - - # get graph name ('@default' is used for the default graph) - name = '@default' - if match[8] is not None: - name = match[8] - elif match[9] is not None: - name = match[9] - - # initialize graph in dataset - if name not in dataset: - dataset[name] = [triple] - # add triple if unique to its graph - else: - unique = True - triples = dataset[name] - for t in dataset[name]: - if _compare_rdf_triples(t, triple): - unique = False - break - if unique: - triples.append(triple) - - return dataset - - -def serialize_nquads(dataset): - """ - Converts an RDF dataset to N-Quads. - - :param dataset: the RDF dataset to convert. - - :return: the N-Quads string. - """ - quads = [] - for graph_name, triples in dataset.items(): - for triple in triples: - if graph_name == '@default': - graph_name = None - quads.append(serialize_nquad(triple, graph_name)) - quads.sort() - return ''.join(quads) - - -def serialize_nquad(triple, graph_name=None): - """ - Converts an RDF triple and graph name to an N-Quad string (a single - quad). - - :param triple: the RDF triple or quad to convert (a triple or quad - may be passed, if a triple is passed then `graph_name` should be - given to specify the name of the graph the triple is in, `None` - for the default graph). - :param graph_name: the name of the graph containing the triple, None - for the default graph. - - :return: the N-Quad string. - """ - s = triple['subject'] - p = triple['predicate'] - o = triple['object'] - g = triple.get('name', {'value': graph_name})['value'] - - quad = '' - - # subject is an IRI - if s['type'] == 'IRI': - quad += '<' + s['value'] + '>' - else: - quad += s['value'] - quad += ' ' - - # property is an IRI - if p['type'] == 'IRI': - quad += '<' + p['value'] + '>' - else: - quad += p['value'] - quad += ' ' - - # object is IRI, bnode, or literal - if o['type'] == 'IRI': - quad += '<' + o['value'] + '>' - elif o['type'] == 'blank node': - quad += o['value'] - else: - escaped = escape(o['value']) - quad += '"' + escaped + '"' - if o['datatype'] == RDF_LANGSTRING: - if o.get('language'): - quad += '@' + o['language'] - elif o['datatype'] != XSD_STRING: - quad += '^^<' + o['datatype'] + '>' - - # graph - if g is not None: - if not g.startswith('_:'): - quad += ' <' + g + '>' - else: - quad += ' ' + g - - quad += ' .\n' - return quad - - -def _compare_rdf_triples(t1, t2): - """ - Compares two RDF triples for equality. - - :param t1: the first triple. - :param t2: the second triple. - - :return: True if the triples are the same, False if not. - """ - for attr in ['subject', 'predicate', 'object']: - if ( - t1[attr]['type'] != t2[attr]['type'] - or t1[attr]['value'] != t2[attr]['value'] - ): - return False - - if t1['object'].get('language') != t2['object'].get('language'): - return False - return t1['object'].get('datatype') == t2['object'].get('datatype') - - -class ParserError(ValueError): - """ - Base class for parsing errors. - """ - - def __init__(self, message, line_number=None): - Exception.__init__(self, message) - self.line_number = line_number diff --git a/lib/pyld/options.py b/lib/pyld/options.py index 349ae1db..4a4e308b 100644 --- a/lib/pyld/options.py +++ b/lib/pyld/options.py @@ -153,8 +153,11 @@ class NormalizeOptions(ProcessingOptions, total=False): processingMode: Literal['json-ld-1.0', 'json-ld-1.1'] """Either `json-ld-1.0` or `json-ld-1.1` (default: `json-ld-1.1`).""" - algorithm: Literal['URDNA2015', 'URGNA2012'] - """The algorithm to use (default: `URGNA2012`).""" + algorithm: Literal['URDNA2015', 'URGNA2012', 'RDFC10'] + """The algorithm to use (default: `RDFC10`).""" + + hashAlgorithm: str + """The hashing algorithm for `RDFC10` (default: `SHA256`).""" inputFormat: Literal['application/n-quads'] """The format if input is not JSON-LD: `application/n-quads` for N-Quads.""" @@ -162,6 +165,9 @@ class NormalizeOptions(ProcessingOptions, total=False): format: Literal['application/n-quads'] """The format if output is a string: `application/n-quads` for N-Quads.""" + outputMap: bool + """`True` to return a blank node identifier map instead of the normalized dataset (default: `False`).""" + class ToRdfOptions(ProcessingOptions, total=False): documentLoader: DocumentLoader | DocumentLoaderCallable @@ -188,6 +194,9 @@ class ToRdfOptions(ProcessingOptions, total=False): rdfDirection: Literal['i18n-datatype', 'compound-literal'] """Either `i18n-datatype` or `compound-literal` is supported (default: `None`).""" + legacyMode: bool + """`True` to return the RDF.js-like dataset `dict` used by PyLD versions lower than 4 instead of an `rdflib.Dataset` when `format` is not set (default: `False`).""" + class FromRdfOptions(TypedDict, total=False): format: Literal['application/n-quads'] diff --git a/lib/pyld/util.py b/lib/pyld/util.py new file mode 100644 index 00000000..82762e9a --- /dev/null +++ b/lib/pyld/util.py @@ -0,0 +1,136 @@ +from rdflib import BNode, Dataset, Literal, URIRef +from rdflib.graph import DATASET_DEFAULT_GRAPH_ID +from rdflib.plugins.parsers.nquads import NQuadsParser +from rdflib.plugins.parsers.ntriples import r_literal, unquote, uriquote + + +# Helpers for converting between rdflib.Dataset and legacy dict structure used in pyld +def to_legacy_dataset(dataset: Dataset) -> dict: + """ + Transforms an rdflib.Dataset into the RDF.js-style dictionary structure, + ensuring Blank Node values start with '_:'. + """ + compat_dataset = {'@default':[]} + + for s, p, o, g in dataset.quads((None, None, None, None)): + # 1. Determine Graph Key + graph_id = '@default' + if g is not None and g != DATASET_DEFAULT_GRAPH_ID: + graph_id = f"_:{str(g)}" if isinstance(g, BNode) else str(g) + + if graph_id not in compat_dataset: + compat_dataset[graph_id] = [] + + # 2. Helper to convert nodes + def term_to_dict(node): + if isinstance(node, BNode): + # Ensure the value starts with _: + val = str(node) + if not val.startswith('_:'): + val = f"_:{val}" + return {'type': 'blank node', 'value': val} + + elif isinstance(node, URIRef): + return {'type': 'IRI', 'value': str(node)} + + elif isinstance(node, Literal): + res = {'type': 'literal', 'value': str(node)} + if node.language: + res['language'] = node.language + if node.datatype: + res['datatype'] = str(node.datatype) + return res + raise ValueError(f'Illegal node type {type(node)}') + + # 3. Build legacy quad + compat_dataset[graph_id].append( + { + 'subject': term_to_dict(s), + 'predicate': term_to_dict(p), + 'object': term_to_dict(o), + } + ) + + return compat_dataset + + +def from_legacy_dataset(dataset: dict) -> Dataset: + """ + Converts legacy dict structure back into an rdflib.Dataset. + """ + ds = Dataset() + + for graph_name, triples in dataset.items(): + # Handle graph name + try: + g = from_legacy_graph(graph_name, ds.default_graph.identifier) + except Exception as err: + raise ValueError(f'Illegal graph name: {graph_name}') from err + + for t in triples: + s, p, o = from_legacy_triple(t) + ds.add((s, p, o, g)) + + return ds + +def from_legacy_graph(graph: str, default_graph = DATASET_DEFAULT_GRAPH_ID) -> URIRef | BNode: + """ + Converts a legacy graph name into an rdflib URIRef or BNode. + """ + if graph == '@default': + return default_graph + # Check if graph name is a blank node or IRI + elif graph.startswith('_:'): + return BNode(graph[2:]) + else: + return URIRef(graph) + +def from_legacy_triple(triple: dict, normalize=False) -> tuple: + """ + Converts a legacy triple dict into an rdflib triple tuple. + """ + if not all(k in triple for k in ('subject', 'predicate', 'object')): + raise ValueError(f'Illegal quad structure: {triple}') + + def to_node(comp): + if not isinstance(comp, dict) or 'type' not in comp or 'value' not in comp: + raise ValueError(f'Illegal quad structure: {comp}') + + val = comp['value'] + if comp['type'] == 'blank node': + # Strip '_:' because RDFLib adds it back internally + return BNode(val[2:] if val.startswith('_:') else val) + elif comp['type'] == 'IRI': + return URIRef(val) + elif comp['type'] == 'literal': + return Literal( + val, + lang=comp.get('language'), + datatype=URIRef(comp['datatype']) + if comp.get('datatype') and not comp.get('language') + else None, + # Don't normalize literal values to prevent datetime issues + # TODO: this means only rdflib.Dataset() created with normalization turned off will work properly. + normalize=normalize, + ) + raise ValueError('Illegal component type {}'.format(comp['type'])) + + s = to_node(triple['subject']) + p = to_node(triple['predicate']) + o = to_node(triple['object']) + + return (s, p, o) + +class UnnormalizedNQuadsParser(NQuadsParser): + """ + Subclass of NQuadsParser that constructs rdflib Literals with + normalize=False, so PyLD preserves N-Quads literal lexical + forms without mutating rdflib.NORMALIZE_LITERALS. + """ + + def literal(self): + if self.peek('"'): + lit, lang, dtype = self.eat(r_literal).groups() + dtype = URIRef(uriquote(unquote(dtype))) if dtype else None + return Literal(unquote(lit), lang or None, dtype, normalize=False) + return False diff --git a/requirements.txt b/requirements.txt index ba951355..630bd175 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ aiohttp; python_version >= '3.5' lxml cachetools frozendict +rdflib \ No newline at end of file diff --git a/setup.py b/setup.py index 29886ee1..371374b9 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ 'cachetools', 'frozendict', 'lxml', + 'rdflib', ], extras_require={ 'requests': ['requests'], diff --git a/specifications/rdf-canon b/specifications/rdf-canon new file mode 160000 index 00000000..15619df2 --- /dev/null +++ b/specifications/rdf-canon @@ -0,0 +1 @@ +Subproject commit 15619df2fda7a4ca88308733789b6774517f9638 diff --git a/tests/runtests.py b/tests/runtests.py index d4835f3f..10b1ea18 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -91,12 +91,14 @@ 'https://w3c.github.io/json-ld-api/tests', 'https://w3c.github.io/json-ld-framing/tests', 'https://github.com/json-ld/normalization/tests', + 'https://w3c.github.io/rdf-canon/tests/vocab#' ] SPEC_DIRS = [ '../specifications/json-ld-api/tests/', '../specifications/json-ld-framing/tests/', '../specifications/normalization/tests/', + '../specifications/rdf-canon/tests/' ] # NOTE: The following TestRunner class can be removed because pytest now @@ -453,28 +455,38 @@ def runTest(self): if self.is_syntax and not self.pending: self.assertTrue(True) elif self.test_type == 'jld:ToRDFTest': - # Test normalized results - result = jsonld.normalize( - result, - { - 'algorithm': 'URGNA2012', - 'inputFormat': 'application/n-quads', - 'format': 'application/n-quads', - }, - ) - expect = jsonld.normalize( - expect, - { - 'algorithm': 'URGNA2012', - 'inputFormat': 'application/n-quads', - 'format': 'application/n-quads', - }, - ) + # avoid normalization when produceGeneralizedRdf is enabled, + # since the rdflib parser cannot parse blank-node predicates. + if data.get('option', {}).get('produceGeneralizedRdf'): + result = _normalize_generalized_nquads_text(result) + expect = _normalize_generalized_nquads_text(expect) + else: + # Test normalized results + result = jsonld.normalize( + result, + { + 'algorithm': 'RDFC10', + 'inputFormat': 'application/n-quads', + 'format': 'application/n-quads', + }, + ) + expect = jsonld.normalize( + expect, + { + 'algorithm': 'RDFC10', + 'inputFormat': 'application/n-quads', + 'format': 'application/n-quads', + }, + ) if _running_under_pytest(): assert result == expect else: assert_results_equal(result, expect) elif not self.is_negative: + # If the result is a dict and the expected value is a string, + # the expected value is probably JSON. + if isinstance(result, dict) and isinstance(expect, str): + expect = json.loads(expect) # Perform order-independent equivalence test if not equal_unordered(result, expect): if _running_under_pytest(): @@ -529,6 +541,26 @@ def _running_under_pytest(): return 'pytest' in sys.modules +def _normalize_generalized_nquads_text(nquads): + """Normalize generalized N-Quads text for test-suite comparisons.""" + # Remove string datatype + nquads = nquads.replace( + '^^ .', + '.', + ) + + # Re-label blank nodes + bnodes = {} + + # Generalized N-Quads expected results only need deterministic comparison, + # not preservation of input blank node labels. + nquads = re.sub(r'_:([A-Za-z][A-Za-z0-9]*)', lambda m: bnodes.setdefault(m[0], f'_:b{len(bnodes)}'), nquads) + lines = (re.sub(r'\s*\.$', '.', line.strip()) for line in nquads.splitlines()) + + # Sort nquads + return '\n'.join(sorted(line for line in lines if line)) + + # Compare values with order-insensitive array tests def equal_unordered(result, expect): """ @@ -677,6 +709,7 @@ def create(test): if k not in http_options: options[k] = v options['documentLoader'] = create_document_loader(test) + options['hashAlgorithm'] = test.data.get('hashAlgorithm') if 'expandContext' in options: filename = os.path.join(test.dirname, options['expandContext']) options['expandContext'] = read_json(filename) @@ -825,7 +858,6 @@ def addSuccess(self, test): def writeReport(self, filename): self.report.write(filename) - class EarlReport: """ Generates an EARL report. @@ -868,7 +900,10 @@ def __init__(self): 'dc:title': 'PyLD', 'doap:homepage': 'https://github.com/digitalbazaar/pyld', 'doap:license': 'https://github.com/digitalbazaar/pyld/blob/master/LICENSE', - 'doap:description': 'A JSON-LD processor for Python', + 'doap:description': { + '@value': 'A JSON-LD processor for Python', + '@language': 'en', + }, 'doap:programming-language': 'Python', 'dc:creator': 'https://github.com/dlongley', 'doap:developer': { @@ -918,7 +953,6 @@ def write(self, filename): # skip tests where behavior changed for a 1.1 processor # see JSON-LD 1.0 Errata 'specVersion': ['json-ld-1.0'], - 'idRegex': [], }, 'fn': 'compact', 'params': [ @@ -950,7 +984,6 @@ def write(self, filename): # skip tests where behavior changed for a 1.1 processor # see JSON-LD 1.0 Errata 'specVersion': ['json-ld-1.0'], - 'idRegex': [], }, 'fn': 'expand', 'params': [read_test_url('input'), create_test_options()], @@ -961,7 +994,6 @@ def write(self, filename): # skip tests where behavior changed for a 1.1 processor # see JSON-LD 1.0 Errata 'specVersion': ['json-ld-1.0'], - 'idRegex': [], }, 'fn': 'flatten', 'params': [ @@ -976,7 +1008,6 @@ def write(self, filename): # skip tests where behavior changed for a 1.1 processor # see JSON-LD 1.0 Errata 'specVersion': ['json-ld-1.0'], - 'idRegex': [], }, 'fn': 'frame', 'params': [ @@ -988,7 +1019,6 @@ def write(self, filename): 'jld:FromRDFTest': { 'skip': { 'specVersion': ['json-ld-1.0'], - 'idRegex': [], }, 'fn': 'from_rdf', 'params': [ @@ -1005,26 +1035,11 @@ def write(self, filename): ], }, 'jld:ToRDFTest': { - 'pending': { - 'idRegex': [ - # blank node property - '.*toRdf-manifest#te075$', - '.*toRdf-manifest#te122$', - # rel vocab - '.*toRdf-manifest#te111$', - '.*toRdf-manifest#te112$', - # well formed - '.*toRdf-manifest#twf05$', - # uncategorized - '.*toRdf-manifest#tli12$', - '.*toRdf-manifest#tli14$', - ] - }, + 'pending': {}, 'skip': { # skip tests where behavior changed for a 1.1 processor # see JSON-LD 1.0 Errata 'specVersion': ['json-ld-1.0'], - 'idRegex': [], }, 'fn': 'to_rdf', 'params': [ @@ -1033,12 +1048,8 @@ def write(self, filename): ], }, 'rdfn:Urgna2012EvalTest': { - 'pending': {'idRegex': []}, - 'skip': { - 'idRegex': [ - '.*manifest-urgna2012#test060$', - ] - }, + 'pending': {}, + 'skip': {}, 'fn': 'normalize', 'params': [ read_test_property('action'), @@ -1052,12 +1063,8 @@ def write(self, filename): ], }, 'rdfn:Urdna2015EvalTest': { - 'pending': {'idRegex': []}, - 'skip': { - 'idRegex': [ - '.*manifest-urdna2015#test060$', - ] - }, + 'pending': {}, + 'skip': {}, 'fn': 'normalize', 'params': [ read_test_property('action'), @@ -1070,6 +1077,33 @@ def write(self, filename): ), ], }, + 'rdfc:RDFC10EvalTest': { + 'pending': {}, + 'skip': {}, + 'fn': 'normalize', + 'params': [ + read_test_property('action'), + create_test_options({ + 'algorithm': 'RDFC10', + 'inputFormat': 'application/n-quads', + 'format': 'application/n-quads' + }) + ] + }, + 'rdfc:RDFC10MapTest': { + 'pending': {}, + 'skip': {}, + 'fn': 'normalize', + 'params': [ + read_test_property('action'), + create_test_options({ + 'algorithm': 'RDFC10', + 'inputFormat': 'application/n-quads', + 'format': 'application/n-quads', + 'outputMap': True + }) + ] + } } diff --git a/tests/test_cli.py b/tests/test_cli.py index b4f9bfdc..9f90b134 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,7 +32,7 @@ } NQUADS = ( ' ' - ' "Ada Lovelace" .\n' + ' "Ada Lovelace"^^ .\n\n' ) diff --git a/tests/test_jsonld.py b/tests/test_jsonld.py index 8b547971..fc5f964e 100644 --- a/tests/test_jsonld.py +++ b/tests/test_jsonld.py @@ -1,4 +1,5 @@ import pytest +from rdflib import Dataset import pyld.jsonld as jsonld from pyld.identifier_issuer import IdentifierIssuer @@ -999,17 +1000,28 @@ def test_double_and_float_values(self): ], } + expected = ( + ' "4.5E1"' + "^^ .\n\n" + ) + result = jsonld.to_rdf(input, {"format": "application/n-quads"}) + assert result == expected + + def test_legacy_mode(self): + """ + legacyMode should return the PyLD 3.x RDF.js-like dataset dict. + """ + input = { + "@context": {"xsd": "http://www.w3.org/2001/XMLSchema#"}, + "@graph": [ + {"@id": "ex:1", "ex:p": {"@type": "xsd:double", "@value": "45"}} + ], + } expected = { "@default": [ { - "subject": { - "type": "IRI", - "value": "ex:1", - }, - "predicate": { - "type": "IRI", - "value": "ex:p", - }, + "subject": {"type": "IRI", "value": "ex:1"}, + "predicate": {"type": "IRI", "value": "ex:p"}, "object": { "type": "literal", "value": "4.5E1", @@ -1019,8 +1031,54 @@ def test_double_and_float_values(self): ] } - result = jsonld.to_rdf(input) - assert result == expected + assert isinstance(jsonld.to_rdf(input), Dataset) + assert jsonld.to_rdf(input, {"legacyMode": True}) == expected + + def test_format_takes_precedence_over_legacy_mode(self): + """ + N-Quads format output should still be returned when legacyMode is true. + """ + input = { + "@context": {"xsd": "http://www.w3.org/2001/XMLSchema#"}, + "@graph": [ + {"@id": "ex:1", "ex:p": {"@type": "xsd:double", "@value": "45"}} + ], + } + + assert jsonld.to_rdf( + input, + {"format": "application/n-quads", "legacyMode": True}, + ) == ( + ' "4.5E1"' + "^^ .\n\n" + ) + + def test_to_rdf_skips_relative_vocab_property_that_expands_to_invalid_iri(self): + """ + to_rdf should omit property IRIs that expand to invalid RDF IRIs. + """ + input = { + "@context": [ + { + "@version": 1.1, + "@base": "http://example.com/some/deep/directory/and/file/", + "@vocab": "http://example.com/vocabulary/", + }, + {"@vocab": "./rel2#"}, + ], + "@id": "relativePropertyIris", + "link": "link", + "#fragment-works": "#fragment-works", + } + + nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'}) + + assert ( + ' ' + ' ' + '"link"^^ .' + ) in nquads + assert '##fragment-works' not in nquads def test_large_integer_to_rdf_double_conversion_processing_mode(self): """ @@ -1035,7 +1093,7 @@ def test_large_integer_to_rdf_double_conversion_processing_mode(self): nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'}) assert nquads == ( ' ' - '"1.0E21"^^ .\n' + '"1.0E21"^^ .\n\n' ) nquads = jsonld.to_rdf( @@ -1048,26 +1106,27 @@ def test_large_integer_to_rdf_double_conversion_processing_mode(self): assert nquads == ( ' ' '"1000000000000000000000"' - '^^ .\n' + '^^ .\n\n' ) def test_to_rdf_uses_identifier_issuer_option(self): input = {'http://example.org/p': [{'@list': ['a', 'b']}]} issuer = IdentifierIssuer('_:custom') + expected = """_:custom0 _:custom1 . +_:custom1 "a"^^ . +_:custom1 _:custom2 . +_:custom2 "b"^^ . +_:custom2 . + +""" + nquads = jsonld.to_rdf( input, options={'format': 'application/n-quads', 'identifierIssuer': issuer}, ) - assert nquads == ( - '_:custom0 _:custom1 .\n' - '_:custom1 "a" .\n' - '_:custom1 _:custom2 .\n' - '_:custom2 "b" .\n' - '_:custom2 ' - ' .\n' - ) + assert sorted(nquads.splitlines()) == sorted(expected.splitlines()) def test_compound_literal_direction_without_language(self): """ @@ -1081,9 +1140,10 @@ def test_compound_literal_direction_without_language(self): } } - expected = """_:b0 _:b1 . -_:b1 "rtl" . -_:b1 "no language" . + expected = """_:b0 _:b1 . +_:b1 "rtl"^^ . +_:b1 "no language"^^ . + """ nquads = jsonld.to_rdf( @@ -1094,7 +1154,7 @@ def test_compound_literal_direction_without_language(self): }, ) - assert nquads == expected + assert sorted(nquads.splitlines()) == sorted(expected.splitlines()) def test_compound_literal_direction_with_language(self): """ @@ -1109,10 +1169,11 @@ def test_compound_literal_direction_with_language(self): } } - expected = """_:b0 _:b1 . -_:b1 "rtl" . -_:b1 "en-us" . -_:b1 "en-US" . + expected = """_:b0 _:b1 . +_:b1 "rtl"^^ . +_:b1 "en-us"^^ . +_:b1 "en-US"^^ . + """ nquads = jsonld.to_rdf( @@ -1123,7 +1184,22 @@ def test_compound_literal_direction_with_language(self): }, ) - assert nquads == expected + assert sorted(nquads.splitlines()) == sorted(expected.splitlines()) + + def test_invalid_language_tag_is_skipped(self): + """ + Conversion to RDF should skip values with invalid language tags. + """ + input = { + '@id': 'http://example.com/foo', + 'http://example.com/bar': { + '@value': 'bar', + '@language': 'a b', + }, + } + + nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'}) + assert nquads == '\n' # Issue 204 def test_conflicting_property_names(self): @@ -1144,13 +1220,15 @@ def test_conflicting_property_names(self): "dublinCore": {"title": "Chapter 1: Jonathan Harker's Journal"}, } - expected = """ _:b0 . - "test" . -_:b0 "Chapter 1: Jonathan Harker's Journal" . + expected = """ "test"^^ . + _:b0 . +_:b0 "Chapter 1: Jonathan Harker's Journal"^^ . + """ nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'}) - assert nquads == expected + # TODO: move this into a helper function for comparing nquads + assert sorted(nquads.splitlines()) == sorted(expected.splitlines()) def test_conflicting_property_names_in_nested_node(self): """ @@ -1170,12 +1248,14 @@ def test_conflicting_property_names_in_nested_node(self): "dublinCore": {"title": "Chapter 1: Jonathan Harker's Journal"}, } - expected = """ "test" . - "Chapter 1: Jonathan Harker's Journal" . + expected = """ "test"^^ . + "Chapter 1: Jonathan Harker's Journal"^^ . + """ nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'}) - assert nquads == expected + # TODO: move this into a helper function for comparing nquads + assert sorted(nquads.splitlines()) == sorted(expected.splitlines()) # Issue 177 def test_fractional(self): @@ -1184,7 +1264,7 @@ def test_fractional(self): """ input = { "ex:value": 42.0 } - expected = '_:b0 "42"^^ .\n' + expected = '_:b0 "42"^^ .\n\n' nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'}) assert nquads == expected @@ -1196,11 +1276,37 @@ def test_truncate_zeros_with_negative_exponent_numbers(self): """ input = { "ex:value": 0.97 } - expected = '_:b0 "9.7E-1"^^ .\n' + expected = '_:b0 "9.7E-1"^^ .\n\n' nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'}) assert nquads == expected + def test_list_skips_invalid_iri_items(self): + """ + Conversion to RDF should skip invalid list item IRIs without dropping + the list node. + """ + input = { + "@context": { + "@base": "http://invalid/<>/", + "list": { + "@id": "foo:bar", + "@container": "@list", + "@type": "@id", + }, + }, + "list": ["test"], + } + + expected = """_:b0 _:b1 . +_:b1 . + +""" + + nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'}) + assert sorted(nquads.splitlines()) == sorted(expected.splitlines()) + + class TestFromRDF: def test_compound_literal_direction_without_language(self): """ diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..0bd6c074 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,320 @@ +import pytest +from rdflib import BNode, Dataset, Literal, URIRef +from rdflib.graph import DATASET_DEFAULT_GRAPH_ID + +from pyld.util import from_legacy_dataset, to_legacy_dataset + + +class TestToLegacyDataset: + """A comprehensive class-based test suite for `to_legacy_dataset` split into granular tests.""" + + def setup_method(self): + """Initialize a clean Dataset before each test.""" + self.dataset = Dataset() + + def test_to_legacy_dataset_empty_dataset_returns_minimal_structure(self): + """Ensure an empty dataset produces a minimal dict with exactly one graph ('@default') and no quads.""" + result = to_legacy_dataset(Dataset()) + assert len(result) == 1 + assert '@default' in result + assert len(result['@default']) == 0 + + def test_to_legacy_dataset_handles_blank_subject(self): + """Ensure blank node subjects are correctly converted to dict with type 'blank node' and value prefixed by '_:'.""" + self.dataset.add((BNode('b1'), URIRef('p1'), Literal('o1', lang='en'))) + result = to_legacy_dataset(self.dataset) + + # Get the quad from @default graph + quads_in_graph = result['@default'] + assert len(quads_in_graph) == 1 + + subject_entry = quads_in_graph[0]['subject'] + assert subject_entry['type'] == 'blank node' + assert subject_entry['value'].startswith('_:') + assert ( + subject_entry['value'][2:] == 'b1' + ) # Check that the original BNode identifier is preserved after prefix + + def test_to_legacy_dataset_handles_blank_object(self): + """Ensure blank node objects are correctly converted to dict with type 'blank node' and prefixed value.""" + self.dataset.add((URIRef('s2'), URIRef('p2'), BNode('b3'))) + result = to_legacy_dataset(self.dataset) + + # Get the quad from @default graph + quads_in_graph = result['@default'] + assert len(quads_in_graph) == 1 + + object_entry = quads_in_graph[0]['object'] + assert object_entry['type'] == 'blank node' + assert object_entry['value'].startswith('_:') + assert ( + object_entry['value'][2:] == 'b3' + ) # Check that the original BNode identifier is preserved after prefix + + def test_to_legacy_dataset_preserves_literal_language(self): + """Ensure literal objects with language are preserved in the output as a 'language' field.""" + + self.dataset.add((URIRef('s1'), URIRef('p2'), Literal('o3', lang='fr'))) + result = to_legacy_dataset(self.dataset) + + # Get the quad from @default graph + quads_in_graph = result['@default'] + assert len(quads_in_graph) == 1 + + object_entry = quads_in_graph[0]['object'] + assert object_entry.get('language') is not None + assert object_entry['language'] == 'fr' + + def test_to_legacy_dataset_preserves_literal_datatype(self): + """Ensure literal objects with a datatype are preserved in the output as 'datatype' field.""" + self.dataset.add( + ( + URIRef('s1'), + URIRef('p2'), + Literal("x", datatype="http://example.org/float"), + ), + ) + result = to_legacy_dataset(self.dataset) + + # Get the quad from @default graph + quads_in_graph = result['@default'] + assert len(quads_in_graph) == 1 + + object_entry = quads_in_graph[0]['object'] + assert object_entry.get('datatype') is not None + assert object_entry['datatype'] == "http://example.org/float" + + def test_to_legacy_dataset_correctly_maps_graph(self): + """Ensure graph key is correctly derived.""" + self.dataset.add( + ( + URIRef('s1'), + URIRef('p2'), + Literal('o3'), + URIRef('http://example.org/graph'), + ) + ) + result = to_legacy_dataset(self.dataset) + + # There should be two graphs: '@default' (empty) and 'http://example.org/graph' (with one quad) + assert len(result.keys()) == 2 + + # Check that '@default' graph exists and is empty + assert '@default' in result + assert len(result['@default']) == 0 + + # Check that 'http://example.org/graph' exists and contains the quad + assert 'http://example.org/graph' in result + assert len(result['http://example.org/graph']) == 1 + + def test_to_legacy_dataset_correctly_maps_bnode_graph(self): + """Ensure graph key is correctly prefixed with '_' for BNodes""" + self.dataset.add((URIRef('s1'), URIRef('p2'), Literal('o3'), BNode('g4'))) + result = to_legacy_dataset(self.dataset) + + # There should be two graphs: '@default' (empty) and '_:g4' (with one quad) + assert len(result.keys()) == 2 + + # Check that '@default' graph exists and is empty + assert '@default' in result + assert len(result['@default']) == 0 + + # Check that '_:g4' graph exists and contains the quad + assert '_:g4' in result + assert len(result['_:g4']) == 1 + + def test_to_legacy_dataset_correctly_maps_default_graph(self): + """Ensure graph key is correctly derived '@default' for None or default graph.""" + self.dataset.add( + (URIRef('s1'), URIRef('p2'), Literal('o3'), DATASET_DEFAULT_GRAPH_ID) + ) + self.dataset.add((URIRef('s2'), URIRef('p2'), Literal('o3'), None)) + print(list(self.dataset.quads((None, None, None, None)))) + result = to_legacy_dataset(self.dataset) + + assert len(result.keys()) == 1 + assert '@default' in result + assert ( + len(result['@default']) == 1 + ) # Only first quad should be in the @default graph + + +class TestFromLegacyDataset: + """A comprehensive class-based test suite for `from_legacy_dataset`, split into granular tests.""" + + def test_from_legacy_dataset_restores_default_graph(self): + """Ensure from_legacy_dataset correctly reconstructs the default graph.""" + legacy_data = { + '@default': [ + { + 'subject': {'type': 'blank node', 'value': '_:s1'}, + 'predicate': {'type': 'IRI', 'value': 'p1'}, + 'object': {'type': 'literal', 'value': 'o1'}, + } + ] + } + + restored_dataset = from_legacy_dataset(legacy_data) + + for quad in restored_dataset.quads((None, None, None, None)): + s, p, o, g = quad + assert g == DATASET_DEFAULT_GRAPH_ID + assert isinstance(s, BNode) + assert str(s) == 's1' + + @pytest.mark.parametrize( + "legacy_data", + [ + { + 'http://example.org': [ + { + 'subject': {'type': 'blank node', 'value': '_:s1'}, + 'predicate': {'type': 'IRI', 'value': 'p1'}, + 'object': {'type': 'literal', 'value': 'o1'}, + } + ] + }, + { + '_:g2': [ + { + 'subject': {'type': 'blank node', 'value': '_:s2'}, + 'predicate': {'type': 'blank node', 'value': '_:p4'}, + 'object': {'type': 'literal', 'value': 'o2'}, + } + ] + }, + ], + ) + def test_from_legacy_dataset_restores_blank_subject(self, legacy_data): + """Ensure from_legacy_dataset correctly reconstructs a blank node subject.""" + restored_dataset = from_legacy_dataset(legacy_data) + + for quad in restored_dataset.quads((None, None, None, None)): + s, p, o, g = quad + if isinstance(s, BNode): + assert not s.startswith('_:') + assert ( + str(s) + == legacy_data['_:' + str(g) if isinstance(g, BNode) else str(g)][ + 0 + ]['subject']['value'][2:] + ) + + @pytest.mark.parametrize( + "legacy_data", + [ + { + '@default': [ + { + 'subject': {'type': 'IRI', 'value': 's1'}, + 'predicate': {'type': 'blank node', 'value': '_:p2'}, + 'object': {'type': 'literal', 'value': 'o3'}, + } + ] + } + ], + ) + def test_from_legacy_dataset_restores_blank_predicate(self, legacy_data): + """Ensure from_legacy_dataset correctly reconstructs a blank node predicate.""" + restored_dataset = from_legacy_dataset(legacy_data) + + for quad in restored_dataset.quads((None, None, None, None)): + s, p, o, g = quad + if isinstance(p, BNode): + assert not str(p).startswith('_:') + assert str(p) == legacy_data['@default'][0]['predicate']['value'][2:] + + @pytest.mark.parametrize( + "legacy_data", + [ + { + '@default': [ + { + 'subject': {'type': 'IRI', 'value': 's1'}, + 'predicate': {'type': 'IRI', 'value': 'p2'}, + 'object': {'type': 'literal', 'value': 'o3', 'language': 'en'}, + } + ] + } + ], + ) + def test_from_legacy_dataset_restores_literal_with_language(self, legacy_data): + """Ensure from_legacy_dataset correctly restores a literal with language.""" + restored_dataset = from_legacy_dataset(legacy_data) + + for quad in restored_dataset.quads((None, None, None, None)): + s, p, o, g = quad + assert o.language == legacy_data['@default'][0]['object']['language'] + + @pytest.mark.parametrize( + "legacy_data", + [ + { + '@default': [ + { + 'subject': {'type': 'IRI', 'value': 's1'}, + 'predicate': {'type': 'IRI', 'value': 'p2'}, + 'object': { + 'type': 'literal', + 'value': 'o3', + 'datatype': 'http://example.org/float', + }, + } + ] + } + ], + ) + def test_from_legacy_dataset_restores_literal_with_datatype(self, legacy_data): + """Ensure from_legacy_dataset correctly restores a literal with datatype.""" + restored_dataset = from_legacy_dataset(legacy_data) + quads = list(restored_dataset.quads((None, None, None, None))) + + for quad in quads: + s, p, o, g = quad + if isinstance(o, Literal) and hasattr(o, 'datatype'): + assert ( + str(o.datatype) == legacy_data['@default'][0]['object']['datatype'] + ) + + def test_from_legacy_dataset_invalid_graph_raises_value_error(self): + """Ensure from_legacy_dataset raises ValueError when given invalid graph name.""" + with pytest.raises(ValueError, match="Illegal graph name: None"): + from_legacy_dataset( + { + None: [ + { + 'subject': {'type': 'IRI', 'value': 's1'}, + 'predicate': {'type': 'IRI', 'value': 'p2'}, + 'object': { + 'type': 'literal', + 'value': 'o3', + 'datatype': 'http://example.org/float', + }, + } + ] + } + ) + + def test_from_legacy_dataset_incomplete_quad_raises_value_error(self): + """Ensure from_legacy_dataset raises ValueError when given invalid input (e.g., missing structure).""" + with pytest.raises(ValueError, match="Illegal quad structure"): + from_legacy_dataset({"@default": [{"subject": {'type': 'IRI', 'value': 's1'}}]}) + + def test_from_legacy_dataset_invalid_quad_term_raises_value_error(self): + """Ensure from_legacy_dataset raises ValueError when given invalid input (e.g., missing structure).""" + with pytest.raises(ValueError, match="Illegal quad structure"): + from_legacy_dataset( + { + "@default": [ + { + 'subject': "bad", + 'predicate': {'type': 'IRI', 'value': 'p2'}, + 'object': { + 'type': 'literal', + 'value': 'o3', + 'datatype': 'http://example.org/float', + }, + } + ] + } + )