mirror of https://gitlab.com/bashrc2/epicyon
4947 lines
181 KiB
Python
4947 lines
181 KiB
Python
"""
|
|
Python implementation of JSON-LD processor
|
|
|
|
This implementation is ported from the JavaScript implementation of
|
|
JSON-LD.
|
|
|
|
.. module:: jsonld
|
|
:synopsis: Python implementation of JSON-LD
|
|
|
|
.. moduleauthor:: Dave Longley
|
|
.. moduleauthor:: Mike Johnson
|
|
.. moduleauthor:: Tim McNamara <tim.mcnamara@okfn.org>
|
|
"""
|
|
|
|
__copyright__ = 'Copyright (c) 2011-2014 Digital Bazaar, Inc.'
|
|
__license__ = 'New BSD license'
|
|
__version__ = '0.6.8'
|
|
__module_group__ = "Security"
|
|
|
|
__all__ = [
|
|
'compact', 'expand', 'flatten', 'frame', 'link', 'from_rdf', 'to_rdf',
|
|
'normalize', 'set_document_loader', 'get_document_loader',
|
|
'parse_link_header', 'load_document',
|
|
'register_rdf_parser', 'unregister_rdf_parser',
|
|
'JsonLdProcessor', 'JsonLdError', 'ActiveContextCache']
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import socket
|
|
import ssl
|
|
import string
|
|
import sys
|
|
import traceback
|
|
from collections import deque, namedtuple
|
|
from numbers import Integral, Real
|
|
|
|
from context import getApschemaV1_9
|
|
from context import getApschemaV1_20
|
|
from context import getApschemaV1_21
|
|
from context import getLitepubV0_1
|
|
from context import get_litepub_social
|
|
from context import get_v1schema
|
|
from context import get_v1security_schema
|
|
from context import get_activitystreams_schema
|
|
|
|
try:
|
|
from functools import cmp_to_key
|
|
except ImportError:
|
|
def cmp_to_key(mycmp):
|
|
"""
|
|
Convert a cmp= function into a key= function
|
|
|
|
Source: http://hg.python.org/cpython/file/default/Lib/functools.py
|
|
"""
|
|
class K(object):
|
|
__slots__ = ['obj']
|
|
|
|
def __init__(self, obj):
|
|
self.obj = obj
|
|
|
|
def __lt__(self, other):
|
|
return mycmp(self.obj, other.obj) < 0
|
|
|
|
def __gt__(self, other):
|
|
return mycmp(self.obj, other.obj) > 0
|
|
|
|
def __eq__(self, other):
|
|
return mycmp(self.obj, other.obj) == 0
|
|
|
|
def __le__(self, other):
|
|
return mycmp(self.obj, other.obj) <= 0
|
|
|
|
def __ge__(self, other):
|
|
return mycmp(self.obj, other.obj) >= 0
|
|
|
|
def __ne__(self, other):
|
|
return mycmp(self.obj, other.obj) != 0
|
|
__hash__ = None
|
|
return K
|
|
|
|
# support python 2
|
|
if sys.version_info[0] >= 3:
|
|
from urllib.request import HTTPSHandler
|
|
import urllib.parse as urllib_parse
|
|
from http.client import HTTPSConnection
|
|
basestring = str
|
|
|
|
def cmp(a, b):
|
|
return (a > b) - (a < b)
|
|
else:
|
|
from urllib2 import HTTPSHandler
|
|
import urlparse as urllib_parse
|
|
from httplib import HTTPSConnection
|
|
|
|
# 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 + 'lang_string'
|
|
|
|
# JSON-LD keywords
|
|
KEYWORDS = [
|
|
'@base',
|
|
'@context',
|
|
'@container',
|
|
'@default',
|
|
'@embed',
|
|
'@explicit',
|
|
'@graph',
|
|
'@id',
|
|
'@index',
|
|
'@language',
|
|
'@list',
|
|
'@omitDefault',
|
|
'@preserve',
|
|
'@requireAll',
|
|
'@reverse',
|
|
'@set',
|
|
'@type',
|
|
'@value',
|
|
'@vocab']
|
|
|
|
# JSON-LD link header rel
|
|
LINK_HEADER_REL = 'http://www.w3.org/ns/json-ld#context'
|
|
|
|
# Restraints
|
|
MAX_CONTEXT_URLS = 10
|
|
|
|
|
|
def compact(input_, ctx, options=None):
|
|
"""
|
|
Performs JSON-LD compaction.
|
|
|
|
:param input_: the JSON-LD input to compact.
|
|
:param ctx: the JSON-LD context to compact with.
|
|
:param [options]: the options to use.
|
|
[base] the base IRI to use.
|
|
[compactArrays] True to compact arrays to single values when
|
|
appropriate, False not to (default: True).
|
|
[graph] True to always output a top-level graph (default: False).
|
|
[expandContext] a context to expand with.
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the compacted JSON-LD output.
|
|
"""
|
|
return JsonLdProcessor().compact(input_, ctx, options)
|
|
|
|
|
|
def expand(input_, options=None):
|
|
"""
|
|
Performs JSON-LD expansion.
|
|
|
|
:param input_: the JSON-LD input to expand.
|
|
:param [options]: the options to use.
|
|
[base] the base IRI to use.
|
|
[expandContext] a context to expand with.
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the expanded JSON-LD output.
|
|
"""
|
|
return JsonLdProcessor().expand(input_, options)
|
|
|
|
|
|
def flatten(input_, ctx=None, options=None):
|
|
"""
|
|
Performs JSON-LD flattening.
|
|
|
|
:param input_: the JSON-LD input to flatten.
|
|
:param ctx: the JSON-LD context to compact with (default: None).
|
|
:param [options]: the options to use.
|
|
[base] the base IRI to use.
|
|
[expandContext] a context to expand with.
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the flattened JSON-LD output.
|
|
"""
|
|
return JsonLdProcessor().flatten(input_, ctx, options)
|
|
|
|
|
|
def frame(input_, frame, options=None):
|
|
"""
|
|
Performs JSON-LD framing.
|
|
|
|
:param input_: the JSON-LD input to frame.
|
|
:param frame: the JSON-LD frame to use.
|
|
:param [options]: the options to use.
|
|
[base] the base IRI to use.
|
|
[expandContext] a context to expand with.
|
|
[embed] default @embed flag (default: True).
|
|
[explicit] default @explicit flag (default: False).
|
|
[requireAll] default @requireAll flag (default: True).
|
|
[omitDefault] default @omitDefault flag (default: False).
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the framed JSON-LD output.
|
|
"""
|
|
return JsonLdProcessor().frame(input_, frame, options)
|
|
|
|
|
|
def link(input_, ctx, options=None):
|
|
"""
|
|
**Experimental**
|
|
|
|
Links a JSON-LD document's nodes in memory.
|
|
|
|
:param input_: the JSON-LD document to link.
|
|
:param ctx: the JSON-LD context to apply or None.
|
|
:param [options]: the options to use.
|
|
[base] the base IRI to use.
|
|
[expandContext] a context to expand with.
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the linked JSON-LD output.
|
|
"""
|
|
# API matches running frame with a wildcard frame and embed: '@link'
|
|
# get arguments
|
|
frame = {'@embed': '@link'}
|
|
if ctx:
|
|
frame['@context'] = ctx
|
|
frame['@embed'] = '@link'
|
|
return frame(input, frame, options)
|
|
|
|
|
|
def normalize(input_: {}, options=None):
|
|
"""
|
|
Performs JSON-LD normalization.
|
|
|
|
:param input_: the JSON-LD input to normalize.
|
|
:param [options]: the options to use.
|
|
[base] the base IRI to use.
|
|
[format] the format if output is a string:
|
|
'application/nquads' for N-Quads.
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the normalized JSON-LD output.
|
|
"""
|
|
return JsonLdProcessor().normalize(input_, options)
|
|
|
|
|
|
def from_rdf(input_, options=None):
|
|
"""
|
|
Converts an RDF dataset to JSON-LD.
|
|
|
|
:param input_: a serialized string of RDF in a format specified
|
|
by the format option or an RDF dataset to convert.
|
|
:param [options]: the options to use:
|
|
[format] the format if input is a string:
|
|
'application/nquads' for N-Quads (default: 'application/nquads').
|
|
[useRdfType] True to use rdf:type, False to use @type (default: False).
|
|
[useNativeTypes] True to convert XSD types into native types
|
|
(boolean, integer, double), False not to (default: True).
|
|
|
|
:return: the JSON-LD output.
|
|
"""
|
|
return JsonLdProcessor().from_rdf(input_, options)
|
|
|
|
|
|
def to_rdf(input_, options=None):
|
|
"""
|
|
Outputs the RDF dataset found in the given JSON-LD object.
|
|
|
|
:param input_: the JSON-LD input.
|
|
:param [options]: the options to use.
|
|
[base] the base IRI to use.
|
|
[format] the format to use to output a string:
|
|
'application/nquads' for N-Quads.
|
|
[produceGeneralizedRdf] true to output generalized RDF, false
|
|
to produce only standard RDF (default: false).
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the resulting RDF dataset (or a serialization of it).
|
|
"""
|
|
return JsonLdProcessor().to_rdf(input_, options)
|
|
|
|
|
|
def set_document_loader(load_document):
|
|
"""
|
|
Sets the default JSON-LD document loader.
|
|
|
|
:param load_document(url): the document loader to use.
|
|
"""
|
|
global _default_document_loader
|
|
_default_document_loader = load_document
|
|
|
|
|
|
def get_document_loader():
|
|
"""
|
|
Gets the default JSON-LD document loader.
|
|
|
|
:return: the default document loader.
|
|
"""
|
|
return _default_document_loader
|
|
|
|
|
|
def parse_link_header(header):
|
|
"""
|
|
Parses a link header. The results will be key'd by the value of "rel".
|
|
|
|
Link: <http://json-ld.org/contexts/person.jsonld>; \
|
|
rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
|
|
|
|
Parses as: {
|
|
'http://www.w3.org/ns/json-ld#context': {
|
|
target: http://json-ld.org/contexts/person.jsonld,
|
|
type: 'application/ld+json'
|
|
}
|
|
}
|
|
|
|
If there is more than one "rel" with the same IRI, then entries in the
|
|
resulting map for that "rel" will be lists.
|
|
|
|
:param header: the link header to parse.
|
|
|
|
:return: the parsed result.
|
|
"""
|
|
rval = {}
|
|
# split on unbracketed/unquoted commas
|
|
entries = re.findall(r'(?:<[^>]*?>|"[^"]*?"|[^,])+', header)
|
|
if not entries:
|
|
return rval
|
|
r_link_header = r'\s*<([^>]*?)>\s*(?:;\s*(.*))?'
|
|
for entry in entries:
|
|
ldmatch = re.search(r_link_header, entry)
|
|
if not ldmatch:
|
|
continue
|
|
ldmatch = ldmatch.groups()
|
|
result = {'target': ldmatch[0]}
|
|
params = ldmatch[1]
|
|
r_params = r'(.*?)=(?:(?:"([^"]*?)")|([^"]*?))\s*(?:(?:;\s*)|$)'
|
|
matches = re.findall(r_params, params)
|
|
for ldmatch in matches:
|
|
result[ldmatch[0]] = \
|
|
ldmatch[2] if ldmatch[1] is None else ldmatch[1]
|
|
rel = result.get('rel', '')
|
|
if isinstance(rval.get(rel), list):
|
|
rval[rel].append(result)
|
|
elif rel in rval:
|
|
rval[rel] = [rval[rel], result]
|
|
else:
|
|
rval[rel] = result
|
|
return rval
|
|
|
|
|
|
def load_document(url):
|
|
"""
|
|
Retrieves JSON-LD at the given URL.
|
|
|
|
:param url: the URL to retrieve.
|
|
|
|
:return: the RemoteDocument.
|
|
"""
|
|
try:
|
|
# validate URL
|
|
pieces = urllib_parse.urlparse(url)
|
|
if (not all([pieces.scheme, pieces.netloc]) or
|
|
pieces.scheme not in ['http', 'https', 'hyper'] or
|
|
set(pieces.netloc) > set(
|
|
string.ascii_letters + string.digits + '-.:')):
|
|
raise JsonLdError(
|
|
'URL could not be dereferenced; only http/https/dat '
|
|
'URLs are supported.',
|
|
'jsonld.InvalidUrl', {'url': url},
|
|
code='loading document failed')
|
|
|
|
if url == 'https://w3id.org/identity/v1':
|
|
doc = {
|
|
'contextUrl': None,
|
|
'documentUrl': url,
|
|
'document': get_v1schema()
|
|
}
|
|
return doc
|
|
if url == 'https://w3id.org/security/v1':
|
|
doc = {
|
|
'contextUrl': None,
|
|
'documentUrl': url,
|
|
'document': get_v1security_schema()
|
|
}
|
|
return doc
|
|
elif url == 'https://www.w3.org/ns/activitystreams':
|
|
doc = {
|
|
'contextUrl': None,
|
|
'documentUrl': url,
|
|
'document': get_activitystreams_schema()
|
|
}
|
|
return doc
|
|
elif url.endswith('/apschema/v1.9'):
|
|
doc = {
|
|
'contextUrl': None,
|
|
'documentUrl': url,
|
|
'document': getApschemaV1_9()
|
|
}
|
|
return doc
|
|
elif url.endswith('/apschema/v1.20'):
|
|
doc = {
|
|
'contextUrl': None,
|
|
'documentUrl': url,
|
|
'document': getApschemaV1_20()
|
|
}
|
|
return doc
|
|
elif url.endswith('/apschema/v1.21'):
|
|
doc = {
|
|
'contextUrl': None,
|
|
'documentUrl': url,
|
|
'document': getApschemaV1_21()
|
|
}
|
|
return doc
|
|
elif url.endswith('/litepub-0.1.jsonld'):
|
|
doc = {
|
|
'contextUrl': None,
|
|
'documentUrl': url,
|
|
'document': getLitepubV0_1()
|
|
}
|
|
return doc
|
|
elif url == 'https://litepub.social/litepub/context.jsonld':
|
|
doc = {
|
|
'contextUrl': None,
|
|
'documentUrl': url,
|
|
'document': get_litepub_social()
|
|
}
|
|
return doc
|
|
return None
|
|
except JsonLdError as ex:
|
|
raise ex
|
|
except Exception as cause:
|
|
raise JsonLdError(
|
|
'Could not retrieve a JSON-LD document from the URL.',
|
|
'jsonld.LoadDocumentError', code='loading document failed',
|
|
cause=cause)
|
|
|
|
|
|
def register_rdf_parser(content_type, parser):
|
|
"""
|
|
Registers a global RDF parser by content-type, for use with
|
|
from_rdf. Global parsers will be used by JsonLdProcessors that
|
|
do not register their own parsers.
|
|
|
|
:param content_type: the content-type for the parser.
|
|
:param parser(input): the parser function (takes a string as
|
|
a parameter and returns an RDF dataset).
|
|
"""
|
|
global _rdf_parsers
|
|
_rdf_parsers[content_type] = parser
|
|
|
|
|
|
def unregister_rdf_parser(content_type):
|
|
"""
|
|
Unregisters a global RDF parser by content-type.
|
|
|
|
:param content_type: the content-type for the parser.
|
|
"""
|
|
global _rdf_parsers
|
|
if content_type in _rdf_parsers:
|
|
del _rdf_parsers[content_type]
|
|
|
|
|
|
def prepend_base(base, iri):
|
|
"""
|
|
Prepends a base IRI to the given relative IRI.
|
|
|
|
:param base: the base IRI.
|
|
:param iri: the relative IRI.
|
|
|
|
:return: the absolute IRI.
|
|
"""
|
|
# skip IRI processing
|
|
if base is None:
|
|
return iri
|
|
|
|
# already an absolute iri
|
|
if _is_absolute_iri(iri):
|
|
return iri
|
|
|
|
# parse IRIs
|
|
base = parse_url(base)
|
|
rel = parse_url(iri)
|
|
|
|
# per RFC3986 5.2.2
|
|
transform = {
|
|
'scheme': base.scheme
|
|
}
|
|
|
|
if rel.authority is not None:
|
|
transform['authority'] = rel.authority
|
|
transform['path'] = rel.path
|
|
transform['query'] = rel.query
|
|
else:
|
|
transform['authority'] = base.authority
|
|
|
|
if rel.path == '':
|
|
transform['path'] = base.path
|
|
if rel.query is not None:
|
|
transform['query'] = rel.query
|
|
else:
|
|
transform['query'] = base.query
|
|
else:
|
|
if rel.path.startswith('/'):
|
|
# IRI represents an absolute path
|
|
transform['path'] = rel.path
|
|
else:
|
|
# merge paths
|
|
path = base.path
|
|
|
|
# append relative path to the end of the last
|
|
# directory from base
|
|
if rel.path != '':
|
|
path = path[0:path.rfind('/') + 1]
|
|
if len(path) > 0 and not path.endswith('/'):
|
|
path += '/'
|
|
path += rel.path
|
|
|
|
transform['path'] = path
|
|
|
|
transform['query'] = rel.query
|
|
|
|
# normalize path
|
|
path = transform['path']
|
|
add_slash = path.endswith('/')
|
|
path = posixpath.normpath(path)
|
|
if not path.endswith('/') and add_slash:
|
|
path += '/'
|
|
# do not include '.' path
|
|
if path == '.':
|
|
path = ''
|
|
transform['path'] = path
|
|
|
|
transform['fragment'] = rel.fragment
|
|
|
|
# construct URL
|
|
rval = unparse_url(transform)
|
|
|
|
# handle empty base case
|
|
if rval == '':
|
|
rval = './'
|
|
|
|
return rval
|
|
|
|
|
|
def remove_base(base, iri):
|
|
"""
|
|
Removes a base IRI from the given absolute IRI.
|
|
|
|
:param base: the base IRI.
|
|
:param iri: the absolute IRI.
|
|
|
|
:return: the relative IRI if relative to base, otherwise the absolute IRI.
|
|
"""
|
|
# skip IRI processing
|
|
if base is None:
|
|
return iri
|
|
|
|
base = parse_url(base)
|
|
rel = parse_url(iri)
|
|
|
|
# schemes and network locations (authorities) don't match, don't alter IRI
|
|
if not (base.scheme == rel.scheme and base.authority == rel.authority):
|
|
return iri
|
|
|
|
path = posixpath.relpath(rel.path, base.path) if rel.path else ''
|
|
path = posixpath.normpath(path)
|
|
# workaround a relpath bug in Python 2.6 (http://bugs.python.org/issue5117)
|
|
if base.path == '/' and path.startswith('../'):
|
|
path = path[3:]
|
|
if path == '.' and not rel.path.endswith('/') and not (
|
|
rel.query or rel.fragment):
|
|
path = posixpath.basename(rel.path)
|
|
if rel.path.endswith('/') and not path.endswith('/'):
|
|
path += '/'
|
|
|
|
# adjustments for base that is not a directory
|
|
if not base.path.endswith('/'):
|
|
if path.startswith('../'):
|
|
path = path[3:]
|
|
elif path.startswith('./'):
|
|
path = path[2:]
|
|
elif path.startswith('.'):
|
|
path = path[1:]
|
|
|
|
return unparse_url((None, None, path, rel.query, rel.fragment)) or './'
|
|
|
|
|
|
ParsedUrl = namedtuple(
|
|
'ParsedUrl', ['scheme', 'authority', 'path', 'query', 'fragment'])
|
|
|
|
|
|
def parse_url(url):
|
|
# regex from RFC 3986
|
|
p = r'^(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?'
|
|
m = re.match(p, url)
|
|
return ParsedUrl(*m.groups())
|
|
|
|
|
|
def unparse_url(parsed):
|
|
if isinstance(parsed, dict):
|
|
parsed = ParsedUrl(**parsed)
|
|
elif isinstance(parsed, list) or isinstance(parsed, tuple):
|
|
parsed = ParsedUrl(*parsed)
|
|
rval = ''
|
|
if parsed.scheme:
|
|
rval += parsed.scheme + ':'
|
|
if parsed.authority is not None:
|
|
rval += '//' + parsed.authority
|
|
rval += parsed.path
|
|
if parsed.query is not None:
|
|
rval += '?' + parsed.query
|
|
if parsed.fragment is not None:
|
|
rval += '#' + parsed.fragment
|
|
return rval
|
|
|
|
|
|
# The default JSON-LD document loader.
|
|
_default_document_loader = load_document
|
|
|
|
# Registered global RDF parsers hashed by content-type.
|
|
_rdf_parsers = {}
|
|
|
|
|
|
class JsonLdProcessor(object):
|
|
"""
|
|
A JSON-LD processor.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""
|
|
Initialize the JSON-LD processor.
|
|
"""
|
|
# processor-specific RDF parsers
|
|
self.rdf_parsers = None
|
|
|
|
def compact(self, input_, ctx, options):
|
|
"""
|
|
Performs JSON-LD compaction.
|
|
|
|
:param input_: the JSON-LD input to compact.
|
|
:param ctx: the context to compact with.
|
|
:param options: the options to use.
|
|
[base] the base IRI to use.
|
|
[compactArrays] True to compact arrays to single values when
|
|
appropriate, False not to (default: True).
|
|
[graph] True to always output a top-level graph (default: False).
|
|
[expandContext] a context to expand with.
|
|
[skipExpansion] True to assume the input is expanded and skip
|
|
expansion, False not to, (default: False).
|
|
[activeCtx] True to also return the active context used.
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the compacted JSON-LD output.
|
|
"""
|
|
if ctx is None:
|
|
raise JsonLdError(
|
|
'The compaction context must not be null.',
|
|
'jsonld.CompactError', code='invalid local context')
|
|
|
|
# nothing to compact
|
|
if input_ is None:
|
|
return None
|
|
|
|
# set default options
|
|
options = options or {}
|
|
options.setdefault('base', input_ if _is_string(input_) else '')
|
|
options.setdefault('compactArrays', True)
|
|
options.setdefault('graph', False)
|
|
options.setdefault('skipExpansion', False)
|
|
options.setdefault('activeCtx', False)
|
|
options.setdefault('documentLoader', _default_document_loader)
|
|
options.setdefault('link', False)
|
|
if options['link']:
|
|
# force skip expansion when linking, "link" is not part of the
|
|
# public API, it should only be called from framing
|
|
options['skipExpansion'] = True
|
|
|
|
if options['skipExpansion']:
|
|
expanded = input_
|
|
else:
|
|
# expand input
|
|
try:
|
|
expanded = self.expand(input_, options)
|
|
except JsonLdError as cause:
|
|
raise JsonLdError(
|
|
'Could not expand input before compaction.',
|
|
'jsonld.CompactError', cause=cause)
|
|
|
|
# process context
|
|
active_ctx = self._get_initial_context(options)
|
|
try:
|
|
active_ctx = self.process_context(active_ctx, ctx, options)
|
|
except JsonLdError as cause:
|
|
raise JsonLdError(
|
|
'Could not process context before compaction.',
|
|
'jsonld.CompactError', cause=cause)
|
|
|
|
# do compaction
|
|
compacted = self._compact(active_ctx, None, expanded, options)
|
|
|
|
if (options['compactArrays'] and not options['graph'] and
|
|
_is_array(compacted)):
|
|
# simplify to a single item
|
|
if len(compacted) == 1:
|
|
compacted = compacted[0]
|
|
# simplify to an empty object
|
|
elif len(compacted) == 0:
|
|
compacted = {}
|
|
# always use an array if graph options is on
|
|
elif options['graph']:
|
|
compacted = JsonLdProcessor.arrayify(compacted)
|
|
|
|
# follow @context key
|
|
if _is_object(ctx) and '@context' in ctx:
|
|
ctx = ctx['@context']
|
|
|
|
# build output context
|
|
ctx = copy.deepcopy(ctx)
|
|
ctx = JsonLdProcessor.arrayify(ctx)
|
|
|
|
# remove empty contexts
|
|
tmp = ctx
|
|
ctx = []
|
|
for v in tmp:
|
|
if not _is_object(v) or len(v) > 0:
|
|
ctx.append(v)
|
|
|
|
# remove array if only one context
|
|
ctx_length = len(ctx)
|
|
has_context = (ctx_length > 0)
|
|
if ctx_length == 1:
|
|
ctx = ctx[0]
|
|
|
|
# add context and/or @graph
|
|
if _is_array(compacted):
|
|
# use '@graph' keyword
|
|
kwgraph = self._compact_iri(active_ctx, '@graph')
|
|
graph = compacted
|
|
compacted = {}
|
|
if has_context:
|
|
compacted['@context'] = ctx
|
|
compacted[kwgraph] = graph
|
|
elif _is_object(compacted) and has_context:
|
|
# reorder keys so @context is first
|
|
graph = compacted
|
|
compacted = {}
|
|
compacted['@context'] = ctx
|
|
for k, v in graph.items():
|
|
compacted[k] = v
|
|
|
|
if options['activeCtx']:
|
|
return {'compacted': compacted, 'activeCtx': active_ctx}
|
|
else:
|
|
return compacted
|
|
|
|
def expand(self, input_, options):
|
|
"""
|
|
Performs JSON-LD expansion.
|
|
|
|
:param input_: the JSON-LD input to expand.
|
|
:param options: the options to use.
|
|
[base] the base IRI to use.
|
|
[expandContext] a context to expand with.
|
|
[keepFreeFloatingNodes] True to keep free-floating nodes,
|
|
False not to (default: False).
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the expanded JSON-LD output.
|
|
"""
|
|
# set default options
|
|
options = options or {}
|
|
options.setdefault('keepFreeFloatingNodes', False)
|
|
options.setdefault('documentLoader', _default_document_loader)
|
|
|
|
# if input is a string, attempt to dereference remote document
|
|
if _is_string(input_):
|
|
remote_doc = options['documentLoader'](input_)
|
|
else:
|
|
remote_doc = {
|
|
'contextUrl': None,
|
|
'documentUrl': None,
|
|
'document': input_
|
|
}
|
|
|
|
try:
|
|
if remote_doc['document'] is None:
|
|
raise JsonLdError(
|
|
'No remote document found at the given URL.',
|
|
'jsonld.NullRemoteDocument')
|
|
if _is_string(remote_doc['document']):
|
|
remote_doc['document'] = json.loads(remote_doc['document'])
|
|
except Exception as cause:
|
|
raise JsonLdError(
|
|
'Could not retrieve a JSON-LD document from the URL.',
|
|
'jsonld.LoadDocumentError',
|
|
{'remoteDoc': remote_doc}, code='loading document failed',
|
|
cause=cause)
|
|
|
|
# set default base
|
|
options.setdefault('base', remote_doc['documentUrl'] or '')
|
|
|
|
# build meta-object and retrieve all @context urls
|
|
input_ = {
|
|
'document': copy.deepcopy(remote_doc['document']),
|
|
'remoteContext': {'@context': remote_doc['contextUrl']}
|
|
}
|
|
if 'expandContext' in options:
|
|
expand_context = copy.deepcopy(options['expandContext'])
|
|
if _is_object(expand_context) and '@context' in expand_context:
|
|
input_['expandContext'] = expand_context
|
|
else:
|
|
input_['expandContext'] = {'@context': expand_context}
|
|
|
|
try:
|
|
self._retrieve_context_urls(
|
|
input_, {}, options['documentLoader'], options['base'])
|
|
except Exception as cause:
|
|
raise JsonLdError(
|
|
'Could not perform JSON-LD expansion.',
|
|
'jsonld.ExpandError', cause=cause)
|
|
|
|
active_ctx = self._get_initial_context(options)
|
|
document = input_['document']
|
|
remote_context = input_['remoteContext']['@context']
|
|
|
|
# process optional expandContext
|
|
if 'expandContext' in input_:
|
|
active_ctx = self.process_context(
|
|
active_ctx, input_['expandContext']['@context'], options)
|
|
|
|
# process remote context from HTTP Link Header
|
|
if remote_context is not None:
|
|
active_ctx = self.process_context(
|
|
active_ctx, remote_context, options)
|
|
|
|
# do expansion
|
|
expanded = self._expand(active_ctx, None, document, options, False)
|
|
|
|
# optimize away @graph with no other properties
|
|
if (_is_object(expanded) and '@graph' in expanded and
|
|
len(expanded) == 1):
|
|
expanded = expanded['@graph']
|
|
elif expanded is None:
|
|
expanded = []
|
|
|
|
# normalize to an array
|
|
return JsonLdProcessor.arrayify(expanded)
|
|
|
|
def flatten(self, input_, ctx, options):
|
|
"""
|
|
Performs JSON-LD flattening.
|
|
|
|
:param input_: the JSON-LD input to flatten.
|
|
:param ctx: the JSON-LD context to compact with (default: None).
|
|
:param options: the options to use.
|
|
[base] the base IRI to use.
|
|
[expandContext] a context to expand with.
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the flattened JSON-LD output.
|
|
"""
|
|
options = options or {}
|
|
options.setdefault('base', input_ if _is_string(input_) else '')
|
|
options.setdefault('documentLoader', _default_document_loader)
|
|
|
|
try:
|
|
# expand input
|
|
expanded = self.expand(input_, options)
|
|
except Exception as cause:
|
|
raise JsonLdError(
|
|
'Could not expand input before flattening.',
|
|
'jsonld.FlattenError', cause=cause)
|
|
|
|
# do flattening
|
|
flattened = self._flatten(expanded)
|
|
|
|
if ctx is None:
|
|
return flattened
|
|
|
|
# compact result (force @graph option to true, skip expansion)
|
|
options['graph'] = True
|
|
options['skipExpansion'] = True
|
|
try:
|
|
compacted = self.compact(flattened, ctx, options)
|
|
except Exception as cause:
|
|
raise JsonLdError(
|
|
'Could not compact flattened output.',
|
|
'jsonld.FlattenError', cause=cause)
|
|
|
|
return compacted
|
|
|
|
def frame(self, input_, frame, options):
|
|
"""
|
|
Performs JSON-LD framing.
|
|
|
|
:param input_: the JSON-LD object to frame.
|
|
:param frame: the JSON-LD frame to use.
|
|
:param options: the options to use.
|
|
[base] the base IRI to use.
|
|
[expandContext] a context to expand with.
|
|
[embed] default @embed flag: '@last', '@always', '@never', '@link'
|
|
(default: '@last').
|
|
[explicit] default @explicit flag (default: False).
|
|
[requireAll] default @requireAll flag (default: True).
|
|
[omitDefault] default @omitDefault flag (default: False).
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the framed JSON-LD output.
|
|
"""
|
|
# set default options
|
|
options = options or {}
|
|
options.setdefault('base', input_ if _is_string(input_) else '')
|
|
options.setdefault('compactArrays', True)
|
|
options.setdefault('embed', '@last')
|
|
options.setdefault('explicit', False)
|
|
options.setdefault('requireAll', True)
|
|
options.setdefault('omitDefault', False)
|
|
options.setdefault('documentLoader', _default_document_loader)
|
|
|
|
# if frame is a string, attempt to dereference remote document
|
|
if _is_string(frame):
|
|
remote_frame = options['documentLoader'](frame)
|
|
else:
|
|
remote_frame = {
|
|
'contextUrl': None,
|
|
'documentUrl': None,
|
|
'document': frame
|
|
}
|
|
|
|
try:
|
|
if remote_frame['document'] is None:
|
|
raise JsonLdError(
|
|
'No remote document found at the given URL.',
|
|
'jsonld.NullRemoteDocument')
|
|
if _is_string(remote_frame['document']):
|
|
remote_frame['document'] = json.loads(remote_frame['document'])
|
|
except Exception as cause:
|
|
raise JsonLdError(
|
|
'Could not retrieve a JSON-LD document from the URL.',
|
|
'jsonld.LoadDocumentError',
|
|
{'remoteDoc': remote_frame}, code='loading document failed',
|
|
cause=cause)
|
|
|
|
# preserve frame context
|
|
frame = remote_frame['document']
|
|
if frame is not None:
|
|
ctx = frame.get('@context', {})
|
|
if remote_frame['contextUrl'] is not None:
|
|
if ctx is not None:
|
|
ctx = remote_frame['contextUrl']
|
|
else:
|
|
ctx = JsonLdProcessor.arrayify(ctx)
|
|
ctx.append(remote_frame['contextUrl'])
|
|
frame['@context'] = ctx
|
|
|
|
try:
|
|
# expand input
|
|
expanded = self.expand(input_, options)
|
|
except JsonLdError as cause:
|
|
raise JsonLdError(
|
|
'Could not expand input before framing.',
|
|
'jsonld.FrameError', cause=cause)
|
|
|
|
try:
|
|
# expand frame
|
|
opts = copy.deepcopy(options)
|
|
opts['keepFreeFloatingNodes'] = True
|
|
expanded_frame = self.expand(frame, opts)
|
|
except JsonLdError as cause:
|
|
raise JsonLdError(
|
|
'Could not expand frame before framing.',
|
|
'jsonld.FrameError', cause=cause)
|
|
|
|
# do framing
|
|
framed = self._frame(expanded, expanded_frame, options)
|
|
|
|
try:
|
|
# compact result (force @graph option to True, skip expansion,
|
|
# check for linked embeds)
|
|
options['graph'] = True
|
|
options['skipExpansion'] = True
|
|
options['link'] = {}
|
|
options['activeCtx'] = True
|
|
result = self.compact(framed, ctx, options)
|
|
except JsonLdError as cause:
|
|
raise JsonLdError(
|
|
'Could not compact framed output.',
|
|
'jsonld.FrameError', cause=cause)
|
|
|
|
compacted = result['compacted']
|
|
active_ctx = result['activeCtx']
|
|
|
|
# get graph alias
|
|
graph = self._compact_iri(active_ctx, '@graph')
|
|
# remove @preserve from results
|
|
compacted[graph] = self._remove_preserve(
|
|
active_ctx, compacted[graph], options)
|
|
return compacted
|
|
|
|
def normalize(self, input_, options):
|
|
"""
|
|
Performs RDF normalization on the given JSON-LD input.
|
|
|
|
:param input_: the JSON-LD input to normalize.
|
|
:param options: the options to use.
|
|
[base] the base IRI to use.
|
|
[format] the format if output is a string:
|
|
'application/nquads' for N-Quads.
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the normalized output.
|
|
"""
|
|
# set default options
|
|
options = options or {}
|
|
options.setdefault('base', input_ if _is_string(input_) else '')
|
|
options.setdefault('documentLoader', _default_document_loader)
|
|
|
|
try:
|
|
# convert to RDF dataset then do normalization
|
|
opts = copy.deepcopy(options)
|
|
if 'format' in opts:
|
|
del opts['format']
|
|
opts['produceGeneralizedRdf'] = False
|
|
dataset = self.to_rdf(input_, opts)
|
|
except JsonLdError as cause:
|
|
raise JsonLdError(
|
|
'Could not convert input to RDF dataset before normalization.',
|
|
'jsonld.NormalizeError', cause=cause)
|
|
|
|
# do normalization
|
|
return self._normalize(dataset, options)
|
|
|
|
def from_rdf(self, dataset, options):
|
|
"""
|
|
Converts an RDF dataset to JSON-LD.
|
|
|
|
:param dataset: a serialized string of RDF in a format specified by
|
|
the format option or an RDF dataset to convert.
|
|
:param options: the options to use.
|
|
[format] the format if input is a string:
|
|
'application/nquads' for N-Quads (default: 'application/nquads').
|
|
[useRdfType] True to use rdf:type, False to use @type
|
|
(default: False).
|
|
[useNativeTypes] True to convert XSD types into native types
|
|
(boolean, integer, double), False not to (default: False).
|
|
|
|
:return: the JSON-LD output.
|
|
"""
|
|
global _rdf_parsers
|
|
|
|
# set default options
|
|
options = options or {}
|
|
options.setdefault('useRdfType', False)
|
|
options.setdefault('useNativeTypes', False)
|
|
|
|
if ('format' not in options) and _is_string(dataset):
|
|
options['format'] = 'application/nquads'
|
|
|
|
# handle special format
|
|
if 'format' in options:
|
|
# supported formats (processor-specific and global)
|
|
if ((self.rdf_parsers is not None and
|
|
not options['format'] in self.rdf_parsers) or
|
|
(self.rdf_parsers is None and
|
|
not options['format'] in _rdf_parsers)):
|
|
raise JsonLdError(
|
|
'Unknown input format.',
|
|
'jsonld.UnknownFormat', {'format': options['format']})
|
|
|
|
if self.rdf_parsers is not None:
|
|
parser = self.rdf_parsers[options['format']]
|
|
else:
|
|
parser = _rdf_parsers[options['format']]
|
|
dataset = parser(dataset)
|
|
|
|
# convert from RDF
|
|
return self._from_rdf(dataset, options)
|
|
|
|
def to_rdf(self, input_, options):
|
|
"""
|
|
Outputs the RDF dataset found in the given JSON-LD object.
|
|
|
|
:param input_: the JSON-LD input.
|
|
:param options: the options to use.
|
|
[base] the base IRI to use.
|
|
[format] the format if input is a string:
|
|
'application/nquads' for N-Quads.
|
|
[produceGeneralizedRdf] true to output generalized RDF, false
|
|
to produce only standard RDF (default: false).
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the resulting RDF dataset (or a serialization of it).
|
|
"""
|
|
# set default options
|
|
options = options or {}
|
|
options.setdefault('base', input_ if _is_string(input_) else '')
|
|
options.setdefault('produceGeneralizedRdf', False)
|
|
options.setdefault('documentLoader', _default_document_loader)
|
|
|
|
try:
|
|
# expand input
|
|
expanded = self.expand(input_, options)
|
|
except JsonLdError as cause:
|
|
raise JsonLdError(
|
|
'Could not expand input before serialization to '
|
|
'RDF.', 'jsonld.RdfError', cause=cause)
|
|
|
|
# create node map for default graph (and any named graphs)
|
|
namer = UniqueNamer('_:b')
|
|
node_map = {'@default': {}}
|
|
self._create_node_map(expanded, node_map, '@default', namer)
|
|
|
|
# output RDF 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, namer, options)
|
|
|
|
# convert to output format
|
|
if 'format' in options:
|
|
if options['format'] == 'application/nquads':
|
|
return self.to_nquads(dataset)
|
|
raise JsonLdError(
|
|
'Unknown output format.',
|
|
'jsonld.UnknownFormat', {'format': options['format']})
|
|
return dataset
|
|
|
|
def process_context(self, active_ctx, local_ctx, options):
|
|
"""
|
|
Processes a local context, retrieving any URLs as necessary, and
|
|
returns a new active context in its callback.
|
|
|
|
:param active_ctx: the current active context.
|
|
:param local_ctx: the local context to process.
|
|
:param options: the options to use.
|
|
[documentLoader(url)] the document loader
|
|
(default: _default_document_loader).
|
|
|
|
:return: the new active context.
|
|
"""
|
|
# return initial context early for None context
|
|
if local_ctx is None:
|
|
return self._get_initial_context(options)
|
|
|
|
# set default options
|
|
options = options or {}
|
|
options.setdefault('base', '')
|
|
options.setdefault('documentLoader', _default_document_loader)
|
|
|
|
# retrieve URLs in local_ctx
|
|
local_ctx = copy.deepcopy(local_ctx)
|
|
if (_is_string(local_ctx) or (
|
|
_is_object(local_ctx) and '@context' not in local_ctx)):
|
|
local_ctx = {'@context': local_ctx}
|
|
try:
|
|
self._retrieve_context_urls(
|
|
local_ctx, {}, options['documentLoader'], options['base'])
|
|
except Exception as cause:
|
|
raise JsonLdError(
|
|
'Could not process JSON-LD context.',
|
|
'jsonld.ContextError', cause=cause)
|
|
|
|
# process context
|
|
return self._process_context(active_ctx, local_ctx, options)
|
|
|
|
def register_rdf_parser(self, content_type, parser):
|
|
"""
|
|
Registers a processor-specific RDF parser by content-type.
|
|
Global parsers will no longer be used by this processor.
|
|
|
|
:param content_type: the content-type for the parser.
|
|
:param parser(input): the parser function (takes a string as
|
|
a parameter and returns an RDF dataset).
|
|
"""
|
|
if self.rdf_parsers is None:
|
|
self.rdf_parsers = {}
|
|
self.rdf_parsers[content_type] = parser
|
|
|
|
def unregister_rdf_parser(self, content_type):
|
|
"""
|
|
Unregisters a process-specific RDF parser by content-type.
|
|
If there are no remaining processor-specific parsers, then the global
|
|
parsers will be re-enabled.
|
|
|
|
:param content_type: the content-type for the parser.
|
|
"""
|
|
if (self.rdf_parsers is not None and
|
|
content_type in self.rdf_parsers):
|
|
del self.rdf_parsers[content_type]
|
|
if len(self.rdf_parsers) == 0:
|
|
self.rdf_parsers = None
|
|
|
|
@staticmethod
|
|
def has_property(subject, property):
|
|
"""
|
|
Returns True if the given subject has the given property.
|
|
|
|
:param subject: the subject to check.
|
|
:param property: the property to look for.
|
|
|
|
:return: True if the subject has the given property, False if not.
|
|
"""
|
|
if property in subject:
|
|
value = subject[property]
|
|
return not _is_array(value) or len(value) > 0
|
|
return False
|
|
|
|
@staticmethod
|
|
def has_value(subject, property, value):
|
|
"""
|
|
Determines if the given value is a property of the given subject.
|
|
|
|
:param subject: the subject to check.
|
|
:param property: the property to check.
|
|
:param value: the value to check.
|
|
|
|
:return: True if the value exists, False if not.
|
|
"""
|
|
if JsonLdProcessor.has_property(subject, property):
|
|
val = subject[property]
|
|
is_list = _is_list(val)
|
|
if _is_array(val) or is_list:
|
|
if is_list:
|
|
val = val['@list']
|
|
for v in val:
|
|
if JsonLdProcessor.compare_values(value, v):
|
|
return True
|
|
# avoid matching the set of values with an array value parameter
|
|
elif not _is_array(value):
|
|
return JsonLdProcessor.compare_values(value, val)
|
|
return False
|
|
|
|
@staticmethod
|
|
def add_value(subject, property, value, options={}):
|
|
"""
|
|
Adds a value to a subject. If the value is an array, all values in the
|
|
array will be added.
|
|
|
|
:param subject: the subject to add the value to.
|
|
:param property: the property that relates the value to the subject.
|
|
:param value: the value to add.
|
|
:param [options]: the options to use:
|
|
[propertyIsArray] True if the property is always
|
|
an array, False if not (default: False).
|
|
[allowDuplicate] True to allow duplicates, False not to (uses
|
|
a simple shallow comparison of subject ID or value)
|
|
(default: True).
|
|
"""
|
|
options.setdefault('propertyIsArray', False)
|
|
options.setdefault('allowDuplicate', True)
|
|
|
|
if _is_array(value):
|
|
if (len(value) == 0 and options['propertyIsArray'] and
|
|
property not in subject):
|
|
subject[property] = []
|
|
for v in value:
|
|
JsonLdProcessor.add_value(subject, property, v, options)
|
|
elif property in subject:
|
|
# check if subject already has value if duplicates not allowed
|
|
has_value = \
|
|
(not options['allowDuplicate'] and
|
|
JsonLdProcessor.has_value(subject, property, value))
|
|
|
|
# make property an array if value not present or always an array
|
|
if (not _is_array(subject[property]) and
|
|
(not has_value or options['propertyIsArray'])):
|
|
subject[property] = [subject[property]]
|
|
|
|
# add new value
|
|
if not has_value:
|
|
subject[property].append(value)
|
|
else:
|
|
# add new value as set or single value
|
|
subject[property] = (
|
|
[value] if options['propertyIsArray'] else value)
|
|
|
|
@staticmethod
|
|
def get_values(subject, property):
|
|
"""
|
|
Gets all of the values for a subject's property as an array.
|
|
|
|
:param subject: the subject.
|
|
:param property: the property.
|
|
|
|
:return: all of the values for a subject's property as an array.
|
|
"""
|
|
return JsonLdProcessor.arrayify(subject.get(property) or [])
|
|
|
|
@staticmethod
|
|
def remove_property(subject, property):
|
|
"""
|
|
Removes a property from a subject.
|
|
|
|
:param subject: the subject.
|
|
:param property: the property.
|
|
"""
|
|
del subject[property]
|
|
|
|
@staticmethod
|
|
def remove_value(subject, property, value, options={}):
|
|
"""
|
|
Removes a value from a subject.
|
|
|
|
:param subject: the subject.
|
|
:param property: the property that relates the value to the subject.
|
|
:param value: the value to remove.
|
|
:param [options]: the options to use:
|
|
[propertyIsArray]: True if the property is always an array,
|
|
False if not (default: False).
|
|
"""
|
|
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))
|
|
|
|
if len(values) == 0:
|
|
JsonLdProcessor.remove_property(subject, property)
|
|
elif len(values) == 1 and not options['propertyIsArray']:
|
|
subject[property] = values[0]
|
|
else:
|
|
subject[property] = values
|
|
|
|
@staticmethod
|
|
def compare_values(v1, v2):
|
|
"""
|
|
Compares two JSON-LD values for equality. Two JSON-LD values will be
|
|
considered equal if:
|
|
|
|
1. They are both primitives of the same type and value.
|
|
2. They are both @values with the same @value, @type, @language,
|
|
and @index, OR
|
|
3. They both have @ids that are the same.
|
|
|
|
:param v1: the first value.
|
|
:param v2: the second value.
|
|
|
|
:return: True if v1 and v2 are considered equal, False if not.
|
|
"""
|
|
# 1. equal primitives
|
|
if not _is_object(v1) and not _is_object(v2) and v1 == v2:
|
|
type1 = type(v1)
|
|
type2 = type(v2)
|
|
if type1 == bool or type2 == bool:
|
|
return type1 == type2
|
|
return True
|
|
|
|
# 2. equal @values
|
|
if (_is_value(v1) and _is_value(v2) and
|
|
v1['@value'] == v2['@value'] and
|
|
v1.get('@type') == v2.get('@type') and
|
|
v1.get('@language') == v2.get('@language') and
|
|
v1.get('@index') == v2.get('@index')):
|
|
type1 = type(v1['@value'])
|
|
type2 = type(v2['@value'])
|
|
if type1 == bool or type2 == bool:
|
|
return type1 == type2
|
|
return True
|
|
|
|
# 3. equal @ids
|
|
if (_is_object(v1) and '@id' in v1 and
|
|
_is_object(v2) and '@id' in v2):
|
|
return v1['@id'] == v2['@id']
|
|
|
|
return False
|
|
|
|
@staticmethod
|
|
def get_context_value(ctx, key, type_):
|
|
"""
|
|
Gets the value for the given active context key and type, None if none
|
|
is set.
|
|
|
|
:param ctx: the active context.
|
|
:param key: the context key.
|
|
:param [type_]: the type of value to get (eg: '@id', '@type'), if not
|
|
specified gets the entire entry for a key, None if not found.
|
|
|
|
:return: mixed the value.
|
|
"""
|
|
rval = None
|
|
|
|
# return None for invalid key
|
|
if key is None:
|
|
return rval
|
|
|
|
# get default language
|
|
if type_ == '@language' and type_ in ctx:
|
|
rval = ctx[type_]
|
|
|
|
# get specific entry information
|
|
if key in ctx['mappings']:
|
|
entry = ctx['mappings'][key]
|
|
if entry is None:
|
|
return None
|
|
|
|
# return whole entry
|
|
if type_ is None:
|
|
rval = entry
|
|
# return entry value for type
|
|
elif type_ in entry:
|
|
rval = entry[type_]
|
|
|
|
return rval
|
|
|
|
@staticmethod
|
|
def parse_nquads(input_):
|
|
"""
|
|
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-z][A-Za-z0-9]*))'
|
|
plain = '"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"'
|
|
datatype = '(?:\\^\\^' + iri + ')'
|
|
language = '(?:@([a-z]+(?:-[a-z0-9]+)*))'
|
|
literal = '(?:' + plain + '(?:' + datatype + '|' + language + ')?)'
|
|
ws = '[ \\t]+'
|
|
wso = '[ \\t]*'
|
|
eoln = r'(?:\r\n)|(?:\n)|(?:\r)'
|
|
empty = r'^' + wso + '$'
|
|
|
|
# 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 = re.split(eoln, input_)
|
|
line_number = 0
|
|
for line in lines:
|
|
line_number += 1
|
|
|
|
# skip empty lines
|
|
if re.search(empty, line) is not None:
|
|
continue
|
|
|
|
# parse quad
|
|
ldmatch = re.search(quad, line)
|
|
if ldmatch is None:
|
|
raise JsonLdError(
|
|
'Error while parsing N-Quads invalid quad.',
|
|
'jsonld.ParseError', {'line': line_number})
|
|
ldmatch = ldmatch.groups()
|
|
|
|
# create RDF triple
|
|
triple = {'subject': {}, 'predicate': {}, 'object': {}}
|
|
|
|
# get subject
|
|
if ldmatch[0] is not None:
|
|
triple['subject'] = {'type': 'IRI', 'value': ldmatch[0]}
|
|
else:
|
|
triple['subject'] = {'type': 'blank node', 'value': ldmatch[1]}
|
|
|
|
# get predicate
|
|
triple['predicate'] = {'type': 'IRI', 'value': ldmatch[2]}
|
|
|
|
# get object
|
|
if ldmatch[3] is not None:
|
|
triple['object'] = {'type': 'IRI', 'value': ldmatch[3]}
|
|
elif ldmatch[4] is not None:
|
|
triple['object'] = {'type': 'blank node', 'value': ldmatch[4]}
|
|
else:
|
|
triple['object'] = {'type': 'literal'}
|
|
replacements = {
|
|
'\\"': '\"',
|
|
'\\t': '\t',
|
|
'\\n': '\n',
|
|
'\\r': '\r',
|
|
'\\\\': '\\'
|
|
}
|
|
unescaped = ldmatch[5]
|
|
for ldmatch, repl in replacements.items():
|
|
unescaped = unescaped.replace(ldmatch, repl)
|
|
if ldmatch[6] is not None:
|
|
triple['object']['datatype'] = ldmatch[6]
|
|
elif ldmatch[7] is not None:
|
|
triple['object']['datatype'] = RDF_LANGSTRING
|
|
triple['object']['language'] = ldmatch[7]
|
|
else:
|
|
triple['object']['datatype'] = XSD_STRING
|
|
triple['object']['value'] = unescaped
|
|
|
|
# get graph name ('@default' is used for the default graph)
|
|
name = '@default'
|
|
if ldmatch[8] is not None:
|
|
name = ldmatch[8]
|
|
elif ldmatch[9] is not None:
|
|
name = ldmatch[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 JsonLdProcessor._compare_rdf_triples(t, triple):
|
|
unique = False
|
|
break
|
|
if unique:
|
|
triples.append(triple)
|
|
|
|
return dataset
|
|
|
|
@staticmethod
|
|
def to_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(JsonLdProcessor.to_nquad(triple, graph_name))
|
|
quads.sort()
|
|
return ''.join(quads)
|
|
|
|
@staticmethod
|
|
def to_nquad(triple, graph_name, bnode=None):
|
|
"""
|
|
Converts an RDF triple and graph name to an N-Quad string (a single
|
|
quad).
|
|
|
|
:param triple: the RDF triple to convert.
|
|
:param graph_name: the name of the graph containing the triple, None
|
|
for the default graph.
|
|
:param bnode: the bnode the quad is mapped to (optional, for
|
|
use during normalization only).
|
|
|
|
:return: the N-Quad string.
|
|
"""
|
|
s = triple['subject']
|
|
p = triple['predicate']
|
|
o = triple['object']
|
|
g = graph_name
|
|
|
|
quad = ''
|
|
|
|
# subject is an IRI
|
|
if s['type'] == 'IRI':
|
|
quad += '<' + s['value'] + '>'
|
|
# bnode normalization mode
|
|
elif bnode is not None:
|
|
quad += '_:a' if s['value'] == bnode else '_:z'
|
|
# bnode normal mode
|
|
else:
|
|
quad += s['value']
|
|
quad += ' '
|
|
|
|
# property is an IRI
|
|
if p['type'] == 'IRI':
|
|
quad += '<' + p['value'] + '>'
|
|
# FIXME: TBD what to do with bnode predicates during normalization
|
|
# bnode normalization mode
|
|
elif bnode is not None:
|
|
quad += '_:p'
|
|
# bnode normal mode
|
|
else:
|
|
quad += p['value']
|
|
quad += ' '
|
|
|
|
# object is IRI, bnode, or literal
|
|
if o['type'] == 'IRI':
|
|
quad += '<' + o['value'] + '>'
|
|
elif(o['type'] == 'blank node'):
|
|
# normalization mode
|
|
if bnode is not None:
|
|
quad += '_:a' if o['value'] == bnode else '_:z'
|
|
# normal mode
|
|
else:
|
|
quad += o['value']
|
|
else:
|
|
replacements = {
|
|
'\\': '\\\\',
|
|
'\t': '\\t',
|
|
'\n': '\\n',
|
|
'\r': '\\r',
|
|
'\"': '\\"'
|
|
}
|
|
escaped = o['value']
|
|
for ldmatch, repl in replacements.items():
|
|
escaped = escaped.replace(ldmatch, repl)
|
|
quad += '"' + escaped + '"'
|
|
if o['datatype'] == RDF_LANGSTRING:
|
|
if o['language']:
|
|
quad += '@' + o['language']
|
|
elif o['datatype'] != XSD_STRING:
|
|
quad += '^^<' + o['datatype'] + '>'
|
|
|
|
# graph
|
|
if g is not None:
|
|
if not g.startswith('_:'):
|
|
quad += ' <' + g + '>'
|
|
elif bnode is not None:
|
|
quad += ' _:g'
|
|
else:
|
|
quad += ' ' + g
|
|
|
|
quad += ' .\n'
|
|
return quad
|
|
|
|
@staticmethod
|
|
def arrayify(value):
|
|
"""
|
|
If value is an array, returns value, otherwise returns an array
|
|
containing value as the only element.
|
|
|
|
:param value: the value.
|
|
|
|
:return: an array.
|
|
"""
|
|
return value if _is_array(value) else [value]
|
|
|
|
@staticmethod
|
|
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
|
|
if t1['object'].get('datatype') != t2['object'].get('datatype'):
|
|
return False
|
|
|
|
return True
|
|
|
|
def _compact(self, active_ctx, active_property, element, options):
|
|
"""
|
|
Recursively compacts an element using the given active context. All
|
|
values must be in expanded form before this method is called.
|
|
|
|
:param active_ctx: the active context to use.
|
|
:param active_property: the compacted property with the element to
|
|
compact, None for none.
|
|
:param element: the element to compact.
|
|
:param options: the compaction options.
|
|
|
|
:return: the compacted value.
|
|
"""
|
|
# recursively compact array
|
|
if _is_array(element):
|
|
rval = []
|
|
for e in element:
|
|
# compact, dropping any None values
|
|
e = self._compact(active_ctx, active_property, e, options)
|
|
if e is not None:
|
|
rval.append(e)
|
|
if options['compactArrays'] and len(rval) == 1:
|
|
# use single element if no container is specified
|
|
container = JsonLdProcessor.get_context_value(
|
|
active_ctx, active_property, '@container')
|
|
if container is None:
|
|
rval = rval[0]
|
|
return rval
|
|
|
|
# recursively compact object
|
|
if _is_object(element):
|
|
if(options['link'] and '@id' in element and
|
|
element['@id'] in options['link']):
|
|
# check for a linked element to reuse
|
|
linked = options['link'][element['@id']]
|
|
for link in linked:
|
|
if link['expanded'] == element:
|
|
return link['compacted']
|
|
|
|
# do value compaction on @values and subject references
|
|
if _is_value(element) or _is_subject_reference(element):
|
|
rval = self._compact_value(
|
|
active_ctx, active_property, element)
|
|
if options['link'] and _is_subject_reference(element):
|
|
# store linked element
|
|
options['link'].setdefault(element['@id'], []).append(
|
|
{'expanded': element, 'compacted': rval})
|
|
return rval
|
|
|
|
# FIXME: avoid misuse of active property as an expanded property?
|
|
inside_reverse = (active_property == '@reverse')
|
|
|
|
rval = {}
|
|
|
|
if options['link'] and '@id' in element:
|
|
# store linked element
|
|
options['link'].setdefault(element['@id'], []).append(
|
|
{'expanded': element, 'compacted': rval})
|
|
|
|
# recursively process element keys in order
|
|
for expanded_property, expanded_value in sorted(element.items()):
|
|
# compact @id and @type(s)
|
|
if expanded_property == '@id' or expanded_property == '@type':
|
|
# compact single @id
|
|
if _is_string(expanded_value):
|
|
compacted_value = self._compact_iri(
|
|
active_ctx, expanded_value,
|
|
vocab=(expanded_property == '@type'))
|
|
# expanded value must be a @type array
|
|
else:
|
|
compacted_value = []
|
|
for ev in expanded_value:
|
|
compacted_value.append(self._compact_iri(
|
|
active_ctx, ev, vocab=True))
|
|
|
|
# use keyword alias and add value
|
|
alias = self._compact_iri(active_ctx, expanded_property)
|
|
is_array = (_is_array(compacted_value) and
|
|
len(compacted_value) == 0)
|
|
JsonLdProcessor.add_value(
|
|
rval, alias, compacted_value,
|
|
{'propertyIsArray': is_array})
|
|
continue
|
|
|
|
# handle @reverse
|
|
if expanded_property == '@reverse':
|
|
# recursively compact expanded value
|
|
compacted_value = self._compact(
|
|
active_ctx, '@reverse', expanded_value, options)
|
|
|
|
# handle double-reversed properties
|
|
for compacted_property, value in \
|
|
list(compacted_value.items()):
|
|
mapping = active_ctx['mappings'].get(
|
|
compacted_property)
|
|
if mapping and mapping['reverse']:
|
|
container = JsonLdProcessor.get_context_value(
|
|
active_ctx, compacted_property, '@container')
|
|
use_array = (container == '@set' or
|
|
not options['compactArrays'])
|
|
JsonLdProcessor.add_value(
|
|
rval, compacted_property, value,
|
|
{'propertyIsArray': use_array})
|
|
del compacted_value[compacted_property]
|
|
|
|
if len(compacted_value.keys()) > 0:
|
|
# use keyword alias and add value
|
|
alias = self._compact_iri(
|
|
active_ctx, expanded_property)
|
|
JsonLdProcessor.add_value(rval, alias, compacted_value)
|
|
|
|
continue
|
|
|
|
# handle @index
|
|
if expanded_property == '@index':
|
|
# drop @index if inside an @index container
|
|
container = JsonLdProcessor.get_context_value(
|
|
active_ctx, active_property, '@container')
|
|
if container == '@index':
|
|
continue
|
|
|
|
# use keyword alias and add value
|
|
alias = self._compact_iri(active_ctx, expanded_property)
|
|
JsonLdProcessor.add_value(rval, alias, expanded_value)
|
|
continue
|
|
|
|
# skip array processing for keywords that aren't
|
|
# @graph or @list
|
|
if(expanded_property != '@graph' and
|
|
expanded_property != '@list' and
|
|
_is_keyword(expanded_property)):
|
|
# use keyword alias and add value as is
|
|
alias = self._compact_iri(active_ctx, expanded_property)
|
|
JsonLdProcessor.add_value(rval, alias, expanded_value)
|
|
continue
|
|
|
|
# Note: expanded value must be an array due to expansion
|
|
# algorithm.
|
|
|
|
# preserve empty arrays
|
|
if len(expanded_value) == 0:
|
|
item_active_property = self._compact_iri(
|
|
active_ctx, expanded_property, expanded_value,
|
|
vocab=True, reverse=inside_reverse)
|
|
JsonLdProcessor.add_value(
|
|
rval, item_active_property, [],
|
|
{'propertyIsArray': True})
|
|
|
|
# recusively process array values
|
|
for expanded_item in expanded_value:
|
|
# compact property and get container type
|
|
item_active_property = self._compact_iri(
|
|
active_ctx, expanded_property, expanded_item,
|
|
vocab=True, reverse=inside_reverse)
|
|
container = JsonLdProcessor.get_context_value(
|
|
active_ctx, item_active_property, '@container')
|
|
|
|
# get @list value if appropriate
|
|
is_list = _is_list(expanded_item)
|
|
list_ = None
|
|
if is_list:
|
|
list_ = expanded_item['@list']
|
|
|
|
# recursively compact expanded item
|
|
compacted_item = self._compact(
|
|
active_ctx, item_active_property,
|
|
list_ if is_list else expanded_item, options)
|
|
|
|
# handle @list
|
|
if is_list:
|
|
# ensure @list is an array
|
|
compacted_item = JsonLdProcessor.arrayify(
|
|
compacted_item)
|
|
|
|
if container != '@list':
|
|
# wrap using @list alias
|
|
wrapper = {}
|
|
wrapper[self._compact_iri(
|
|
active_ctx, '@list')] = compacted_item
|
|
compacted_item = wrapper
|
|
|
|
# include @index from expanded @list, if any
|
|
if '@index' in expanded_item:
|
|
alias = self._compact_iri(active_ctx, '@index')
|
|
compacted_item[alias] = (
|
|
expanded_item['@index'])
|
|
# can't use @list container for more than 1 list
|
|
elif item_active_property in rval:
|
|
raise JsonLdError(
|
|
'JSON-LD compact error; property has a '
|
|
'"@list" @container rule but there is more '
|
|
'than a single @list that matches the '
|
|
'compacted term in the document. Compaction '
|
|
'might mix unwanted items into the list.',
|
|
'jsonld.SyntaxError',
|
|
code='compaction to list of lists')
|
|
|
|
# handle language and index maps
|
|
if container == '@language' or container == '@index':
|
|
# get or create the map object
|
|
map_object = rval.setdefault(item_active_property, {})
|
|
|
|
# if container is a language map, simplify compacted
|
|
# value to a simple string
|
|
if (container == '@language' and
|
|
_is_value(compacted_item)):
|
|
compacted_item = compacted_item['@value']
|
|
|
|
# add compact value to map object using key from
|
|
# expanded value based on the container type
|
|
JsonLdProcessor.add_value(
|
|
map_object, expanded_item[container],
|
|
compacted_item)
|
|
else:
|
|
# use an array if compactArrays flag is false,
|
|
# @container is @set or @list, value is an empty
|
|
# array, or key is @graph
|
|
is_array = (not options['compactArrays'] or
|
|
container == '@set' or
|
|
container == '@list' or
|
|
(_is_array(compacted_item) and
|
|
len(compacted_item) == 0) or
|
|
expanded_property == '@list' or
|
|
expanded_property == '@graph')
|
|
|
|
# add compact value
|
|
JsonLdProcessor.add_value(
|
|
rval, item_active_property, compacted_item,
|
|
{'propertyIsArray': is_array})
|
|
|
|
return rval
|
|
|
|
# only primitives remain which are already compact
|
|
return element
|
|
|
|
def _expand(
|
|
self, active_ctx, active_property, element, options, inside_list):
|
|
"""
|
|
Recursively expands an element using the given context. Any context in
|
|
the element will be removed. All context URLs must have been retrieved
|
|
before calling this method.
|
|
|
|
:param active_ctx: the context to use.
|
|
:param active_property: the property for the element, None for none.
|
|
:param element: the element to expand.
|
|
:param options: the expansion options.
|
|
:param inside_list: True if the property is a list, False if not.
|
|
|
|
:return: the expanded value.
|
|
"""
|
|
# nothing to expand
|
|
if element is None:
|
|
return element
|
|
|
|
# recursively expand array
|
|
if _is_array(element):
|
|
rval = []
|
|
container = JsonLdProcessor.get_context_value(
|
|
active_ctx, active_property, '@container')
|
|
inside_list = inside_list or container == '@list'
|
|
for e in element:
|
|
# expand element
|
|
e = self._expand(
|
|
active_ctx, active_property, e, options, inside_list)
|
|
if inside_list and (_is_array(e) or _is_list(e)):
|
|
# lists of lists are illegal
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; lists of lists are not '
|
|
'permitted.', 'jsonld.SyntaxError',
|
|
code='list of lists')
|
|
# drop None values
|
|
if e is not None:
|
|
if _is_array(e):
|
|
rval.extend(e)
|
|
else:
|
|
rval.append(e)
|
|
return rval
|
|
|
|
# handle scalars
|
|
if not _is_object(element):
|
|
# drop free-floating scalars that are not in lists
|
|
if (not inside_list and (active_property is None or
|
|
self._expand_iri(
|
|
active_ctx, active_property, vocab=True) == '@graph')):
|
|
return None
|
|
|
|
# expand element according to value expansion rules
|
|
return self._expand_value(active_ctx, active_property, element)
|
|
|
|
# recursively expand object
|
|
# if element has a context, process it
|
|
if '@context' in element:
|
|
active_ctx = self._process_context(
|
|
active_ctx, element['@context'], options)
|
|
|
|
# expand the active property
|
|
expanded_active_property = self._expand_iri(
|
|
active_ctx, active_property, vocab=True)
|
|
|
|
rval = {}
|
|
for key, value in sorted(element.items()):
|
|
if key == '@context':
|
|
continue
|
|
|
|
# expand key to IRI
|
|
expanded_property = self._expand_iri(
|
|
active_ctx, key, vocab=True)
|
|
|
|
# drop non-absolute IRI keys that aren't keywords
|
|
if (expanded_property is None or not
|
|
(_is_absolute_iri(expanded_property) or
|
|
_is_keyword(expanded_property))):
|
|
continue
|
|
|
|
if _is_keyword(expanded_property):
|
|
if expanded_active_property == '@reverse':
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; a keyword cannot be used as '
|
|
'a @reverse property.',
|
|
'jsonld.SyntaxError', {'value': value},
|
|
code='invalid reverse property map')
|
|
if expanded_property in rval:
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; colliding keywords detected.',
|
|
'jsonld.SyntaxError', {'keyword': expanded_property},
|
|
code='colliding keywords')
|
|
|
|
# syntax error if @id is not a string
|
|
if expanded_property == '@id' and not _is_string(value):
|
|
if not options.get('isFrame'):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; "@id" value must a string.',
|
|
'jsonld.SyntaxError', {'value': value},
|
|
code='invalid @id value')
|
|
if not _is_object(value):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; "@id" value must be a '
|
|
'string or an object.', 'jsonld.SyntaxError',
|
|
{'value': value}, code='invalid @id value')
|
|
|
|
if expanded_property == '@type':
|
|
_validate_type_value(value)
|
|
|
|
# @graph must be an array or an object
|
|
if (expanded_property == '@graph' and
|
|
not (_is_object(value) or _is_array(value))):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; "@graph" must not be an '
|
|
'object or an array.', 'jsonld.SyntaxError',
|
|
{'value': value}, code='invalid @graph value')
|
|
|
|
# @value must not be an object or an array
|
|
if (expanded_property == '@value' and
|
|
(_is_object(value) or _is_array(value))):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; "@value" value must not be an '
|
|
'object or an array.', 'jsonld.SyntaxError',
|
|
{'value': value}, code='invalid value object value')
|
|
|
|
# @language must be a string
|
|
if expanded_property == '@language':
|
|
if value is None:
|
|
# drop null @language values, they expand as if they
|
|
# didn't exist
|
|
continue
|
|
if not _is_string(value):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; "@language" value must be '
|
|
'a string.', 'jsonld.SyntaxError', {'value': value},
|
|
code='invalid language-tagged string')
|
|
# ensure language value is lowercase
|
|
value = value.lower()
|
|
|
|
# @index must be a string
|
|
if expanded_property == '@index' and not _is_string(value):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; "@index" value must be '
|
|
'a string.', 'jsonld.SyntaxError', {'value': value},
|
|
code='invalid @index value')
|
|
|
|
# reverse must be an object
|
|
if expanded_property == '@reverse':
|
|
if not _is_object(value):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; "@reverse" value must be '
|
|
'an object.', 'jsonld.SyntaxError', {'value': value},
|
|
code='invalid @reverse value')
|
|
|
|
expanded_value = self._expand(
|
|
active_ctx, '@reverse', value, options, inside_list)
|
|
|
|
# properties double-reversed
|
|
if '@reverse' in expanded_value:
|
|
for rprop, rvalue in expanded_value['@reverse'].items():
|
|
JsonLdProcessor.add_value(
|
|
rval, rprop, rvalue,
|
|
{'propertyIsArray': True})
|
|
|
|
# merge in all reversed properties
|
|
reverse_map = rval.get('@reverse')
|
|
for property, items in expanded_value.items():
|
|
if property == '@reverse':
|
|
continue
|
|
if reverse_map is None:
|
|
reverse_map = rval['@reverse'] = {}
|
|
JsonLdProcessor.add_value(
|
|
reverse_map, property, [],
|
|
{'propertyIsArray': True})
|
|
for item in items:
|
|
if _is_value(item) or _is_list(item):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; "@reverse" '
|
|
'value must not be an @value or an @list',
|
|
'jsonld.SyntaxError',
|
|
{'value': expanded_value},
|
|
code='invalid reverse property value')
|
|
JsonLdProcessor.add_value(
|
|
reverse_map, property, item,
|
|
{'propertyIsArray': True})
|
|
|
|
continue
|
|
|
|
container = JsonLdProcessor.get_context_value(
|
|
active_ctx, key, '@container')
|
|
|
|
# handle language map container (skip if value is not an object)
|
|
if container == '@language' and _is_object(value):
|
|
expanded_value = self._expand_language_map(value)
|
|
# handle index container (skip if value is not an object)
|
|
elif container == '@index' and _is_object(value):
|
|
def expand_index_map(active_property):
|
|
rval = []
|
|
for k, v in sorted(value.items()):
|
|
v = self._expand(
|
|
active_ctx, active_property,
|
|
JsonLdProcessor.arrayify(v),
|
|
options, inside_list=False)
|
|
for item in v:
|
|
item.setdefault('@index', k)
|
|
rval.append(item)
|
|
return rval
|
|
expanded_value = expand_index_map(key)
|
|
else:
|
|
# recurse into @list or @set
|
|
is_list = (expanded_property == '@list')
|
|
if is_list or expanded_property == '@set':
|
|
next_active_property = active_property
|
|
if is_list and expanded_active_property == '@graph':
|
|
next_active_property = None
|
|
expanded_value = self._expand(
|
|
active_ctx, next_active_property, value, options,
|
|
is_list)
|
|
if is_list and _is_list(expanded_value):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; lists of lists are '
|
|
'not permitted.', 'jsonld.SyntaxError',
|
|
code='list of lists')
|
|
else:
|
|
# recursively expand value w/key as new active property
|
|
expanded_value = self._expand(
|
|
active_ctx, key, value, options, inside_list=False)
|
|
|
|
# drop None values if property is not @value (dropped below)
|
|
if expanded_value is None and expanded_property != '@value':
|
|
continue
|
|
|
|
# convert expanded value to @list if container specifies it
|
|
if (expanded_property != '@list' and not _is_list(expanded_value)
|
|
and container == '@list'):
|
|
# ensure expanded value is an array
|
|
expanded_value = {
|
|
'@list': JsonLdProcessor.arrayify(expanded_value)
|
|
}
|
|
|
|
# merge in reverse properties
|
|
mapping = active_ctx['mappings'].get(key)
|
|
if mapping and mapping['reverse']:
|
|
reverse_map = rval.setdefault('@reverse', {})
|
|
expanded_value = JsonLdProcessor.arrayify(expanded_value)
|
|
for item in expanded_value:
|
|
if _is_value(item) or _is_list(item):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; "@reverse" value must '
|
|
'not be an @value or an @list.',
|
|
'jsonld.SyntaxError', {'value': expanded_value},
|
|
code='invalid reverse property value')
|
|
JsonLdProcessor.add_value(
|
|
reverse_map, expanded_property, item,
|
|
{'propertyIsArray': True})
|
|
continue
|
|
|
|
# add value for property, use an array exception for certain
|
|
# key words
|
|
use_array = (expanded_property not in ['@index', '@id', '@type',
|
|
'@value', '@language'])
|
|
JsonLdProcessor.add_value(
|
|
rval, expanded_property, expanded_value,
|
|
{'propertyIsArray': use_array})
|
|
|
|
# get property count on expanded output
|
|
count = len(rval)
|
|
|
|
if '@value' in rval:
|
|
# @value must only have @language or @type
|
|
if '@type' in rval and '@language' in rval:
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; an element containing '
|
|
'"@value" may not contain both "@type" and "@language".',
|
|
'jsonld.SyntaxError', {'element': rval},
|
|
code='invalid value object')
|
|
valid_count = count - 1
|
|
if '@type' in rval:
|
|
valid_count -= 1
|
|
if '@index' in rval:
|
|
valid_count -= 1
|
|
if '@language' in rval:
|
|
valid_count -= 1
|
|
if valid_count != 0:
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; an element containing "@value" '
|
|
'may only have an "@index" property and at most one other '
|
|
'property which can be "@type" or "@language".',
|
|
'jsonld.SyntaxError', {'element': rval},
|
|
code='invalid value object')
|
|
# drop None @values
|
|
if rval['@value'] is None:
|
|
rval = None
|
|
# if @language is present, @value must be a string
|
|
elif '@language' in rval and not _is_string(rval['@value']):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; only strings may be '
|
|
'language-tagged.', 'jsonld.SyntaxError',
|
|
{'element': rval}, code='invalid language-tagged value')
|
|
elif ('@type' in rval and (not _is_absolute_iri(rval['@type']) or
|
|
rval['@type'].startswith('_:'))):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; an element containing "@value" '
|
|
'and "@type" must have an absolute IRI for the value '
|
|
'of "@type".', 'jsonld.SyntaxError', {'element': rval},
|
|
code='invalid typed value')
|
|
# convert @type to an array
|
|
elif '@type' in rval and not _is_array(rval['@type']):
|
|
rval['@type'] = [rval['@type']]
|
|
# handle @set and @list
|
|
elif '@set' in rval or '@list' in rval:
|
|
if count > 1 and not (count == 2 and '@index' in rval):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; if an element has the '
|
|
'property "@set" or "@list", then it can have at most '
|
|
'one other property, which is "@index".',
|
|
'jsonld.SyntaxError', {'element': rval},
|
|
code='invalid set or list object')
|
|
# optimize away @set
|
|
if '@set' in rval:
|
|
rval = rval['@set']
|
|
count = len(rval)
|
|
# drop objects with only @language
|
|
elif count == 1 and '@language' in rval:
|
|
rval = None
|
|
|
|
# drop certain top-level objects that do not occur in lists
|
|
if (_is_object(rval) and not options.get('keepFreeFloatingNodes') and
|
|
not inside_list and (active_property is None or
|
|
expanded_active_property == '@graph')):
|
|
# drop empty object or top-level @value/@list,
|
|
# or object with only @id
|
|
if (count == 0 or '@value' in rval or '@list' in rval or
|
|
(count == 1 and '@id' in rval)):
|
|
rval = None
|
|
|
|
return rval
|
|
|
|
def _flatten(self, input):
|
|
"""
|
|
Performs JSON-LD flattening.
|
|
|
|
:param input_: the expanded JSON-LD to flatten.
|
|
|
|
:return: the flattened JSON-LD output.
|
|
"""
|
|
# produce a map of all subjects and name each bnode
|
|
namer = UniqueNamer('_:b')
|
|
graphs = {'@default': {}}
|
|
self._create_node_map(input, graphs, '@default', namer)
|
|
|
|
# add all non-default graphs to default graph
|
|
default_graph = graphs['@default']
|
|
for graph_name, node_map in graphs.items():
|
|
if graph_name == '@default':
|
|
continue
|
|
graph_subject = default_graph.setdefault(
|
|
graph_name, {'@id': graph_name, '@graph': []})
|
|
graph_subject.setdefault('@graph', []).extend(
|
|
[v for k, v in sorted(node_map.items())
|
|
if not _is_subject_reference(v)])
|
|
|
|
# produce flattened output
|
|
return [value for key, value in sorted(default_graph.items())
|
|
if not _is_subject_reference(value)]
|
|
|
|
def _frame(self, input_, frame, options):
|
|
"""
|
|
Performs JSON-LD framing.
|
|
|
|
:param input_: the expanded JSON-LD to frame.
|
|
:param frame: the expanded JSON-LD frame to use.
|
|
:param options: the framing options.
|
|
|
|
:return: the framed output.
|
|
"""
|
|
# create framing state
|
|
state = {
|
|
'options': options,
|
|
'graphs': {'@default': {}, '@merged': {}},
|
|
'subjectStack': [],
|
|
'link': {}
|
|
}
|
|
|
|
# produce a map of all graphs and name each bnode
|
|
# FIXME: currently uses subjects from @merged graph only
|
|
namer = UniqueNamer('_:b')
|
|
self._create_node_map(input_, state['graphs'], '@merged', namer)
|
|
state['subjects'] = state['graphs']['@merged']
|
|
|
|
# frame the subjects
|
|
framed = []
|
|
self._match_frame(
|
|
state, sorted(state['subjects'].keys()), frame, framed, None)
|
|
return framed
|
|
|
|
def _normalize(self, dataset, options):
|
|
"""
|
|
Performs RDF normalization on the given RDF dataset.
|
|
|
|
:param dataset: the RDF dataset to normalize.
|
|
:param options: the normalization options.
|
|
|
|
:return: the normalized output.
|
|
"""
|
|
# create quads and map bnodes to their associated quads
|
|
quads = []
|
|
bnodes = {}
|
|
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
|
|
quads.append(quad)
|
|
|
|
for attr in ['subject', 'object', 'name']:
|
|
if attr in quad and quad[attr]['type'] == 'blank node':
|
|
id_ = quad[attr]['value']
|
|
bnodes.setdefault(id_, {}).setdefault(
|
|
'quads', []).append(quad)
|
|
|
|
# mapping complete, start canonical naming
|
|
namer = UniqueNamer('_:c14n')
|
|
|
|
# continue to hash bnode quads while bnodes are assigned names
|
|
unnamed = None
|
|
next_unnamed = bnodes.keys()
|
|
duplicates = None
|
|
while True:
|
|
unnamed = next_unnamed
|
|
next_unnamed = []
|
|
duplicates = {}
|
|
unique = {}
|
|
for bnode in unnamed:
|
|
# hash quads for each unnamed bnode
|
|
hash = self._hash_quads(bnode, bnodes)
|
|
|
|
# store hash as unique or a duplicate
|
|
if hash in duplicates:
|
|
duplicates[hash].append(bnode)
|
|
next_unnamed.append(bnode)
|
|
elif hash in unique:
|
|
duplicates[hash] = [unique[hash], bnode]
|
|
next_unnamed.append(unique[hash])
|
|
next_unnamed.append(bnode)
|
|
del unique[hash]
|
|
else:
|
|
unique[hash] = bnode
|
|
|
|
# name unique bnodes in sorted hash order
|
|
for hash, bnode in sorted(unique.items()):
|
|
namer.get_name(bnode)
|
|
|
|
# done when no more bnodes named
|
|
if len(unnamed) == len(next_unnamed):
|
|
break
|
|
|
|
# enumerate duplicate hash groups in sorted order
|
|
for hash, group in sorted(duplicates.items()):
|
|
# process group
|
|
results = []
|
|
for bnode in group:
|
|
# skip already-named bnodes
|
|
if namer.is_named(bnode):
|
|
continue
|
|
|
|
# hash bnode paths
|
|
path_namer = UniqueNamer('_:b')
|
|
path_namer.get_name(bnode)
|
|
try:
|
|
bnode_path = self._hash_paths(
|
|
bnode, bnodes, namer, path_namer)
|
|
results.append(bnode_path)
|
|
except BaseException:
|
|
print('WARN: jsonld bnode_path failed')
|
|
pass
|
|
|
|
# name bnodes in hash order
|
|
cmp_hashes = cmp_to_key(lambda x, y: cmp(x['hash'], y['hash']))
|
|
for result in sorted(results, key=cmp_hashes):
|
|
# name all bnodes in path namer in key-entry order
|
|
for bnode in result['pathNamer'].order:
|
|
namer.get_name(bnode)
|
|
|
|
# create normalized array
|
|
normalized = []
|
|
|
|
# Note: At this point all bnodes in the set of RDF quads have been
|
|
# assigned canonical names, which have been stored in the 'namer'
|
|
# object. Here each quad is updated by assigning each of its bnodes its
|
|
# new name via the 'namer' object.
|
|
|
|
# update bnode names in each quad and serialize
|
|
for quad in quads:
|
|
for attr in ['subject', 'object', 'name']:
|
|
if (attr in quad and
|
|
quad[attr]['type'] == 'blank node' and
|
|
not quad[attr]['value'].startswith('_:c14n')):
|
|
quad[attr]['value'] = namer.get_name(quad[attr]['value'])
|
|
normalized.append(JsonLdProcessor.to_nquad(
|
|
quad, quad['name']['value'] if 'name' in quad else None))
|
|
|
|
# sort normalized output
|
|
normalized.sort()
|
|
|
|
# handle output format
|
|
if 'format' in options:
|
|
if options['format'] == 'application/nquads':
|
|
return ''.join(normalized)
|
|
raise JsonLdError(
|
|
'Unknown output format.',
|
|
'jsonld.UnknownFormat', {'format': options['format']})
|
|
|
|
# return parsed RDF dataset
|
|
return JsonLdProcessor.parse_nquads(''.join(normalized))
|
|
|
|
def _from_rdf(self, dataset, options):
|
|
"""
|
|
Converts an RDF dataset to JSON-LD.
|
|
|
|
:param dataset: the RDF dataset.
|
|
:param options: the RDF serialization options.
|
|
|
|
:return: the JSON-LD output.
|
|
"""
|
|
default_graph = {}
|
|
graph_map = {'@default': default_graph}
|
|
referenced_once = {}
|
|
|
|
for name, graph in dataset.items():
|
|
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})
|
|
|
|
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']}
|
|
|
|
if (p == RDF_TYPE and not options.get('useRdfType', False) and
|
|
object_is_id):
|
|
JsonLdProcessor.add_value(
|
|
node, '@type', o['value'], {'propertyIsArray': True})
|
|
continue
|
|
|
|
value = self._rdf_to_object(o, options['useNativeTypes'])
|
|
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
|
|
}
|
|
|
|
# convert linked lists to @list arrays
|
|
for name, graph_object in graph_map.items():
|
|
# no @lists to be converted, continue
|
|
if RDF_NIL not in graph_object:
|
|
continue
|
|
|
|
# iterate backwards through each RDF list
|
|
nil = graph_object[RDF_NIL]
|
|
for usage in nil['usages']:
|
|
node = usage['node']
|
|
property = usage['property']
|
|
head = usage['value']
|
|
list_ = []
|
|
list_nodes = []
|
|
|
|
# ensure node is a well-formed list node; it must:
|
|
# 1. Be referenced only once.
|
|
# 2. Have an array for rdf:first that has 1 item.
|
|
# 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())
|
|
while(property == 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
|
|
(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))):
|
|
list_.append(node[RDF_FIRST][0])
|
|
list_nodes.append(node['@id'])
|
|
|
|
# get next node, moving backwards through list
|
|
usage = referenced_once[node['@id']]
|
|
node = usage['node']
|
|
property = usage['property']
|
|
head = usage['value']
|
|
node_key_count = len(node.keys())
|
|
|
|
# if node is not a blank node, then list head found
|
|
if not node['@id'].startswith('_:'):
|
|
break
|
|
|
|
# the list is nested in another list
|
|
if property == RDF_FIRST:
|
|
# empty list
|
|
if node['@id'] == RDF_NIL:
|
|
# can't convert rdf:nil to a @list object because it
|
|
# would result in a list of lists which isn't supported
|
|
continue
|
|
|
|
# preserve list head
|
|
head = graph_object[head['@id']][RDF_REST][0]
|
|
list_.pop()
|
|
list_nodes.pop()
|
|
|
|
# transform list into @list object
|
|
del head['@id']
|
|
list_.reverse()
|
|
head['@list'] = list_
|
|
for node in list_nodes:
|
|
graph_object.pop(node, None)
|
|
|
|
nil.pop('usages', None)
|
|
|
|
result = []
|
|
for subject, node in sorted(default_graph.items()):
|
|
if subject in graph_map:
|
|
graph = node['@graph'] = []
|
|
for s, n in sorted(graph_map[subject].items()):
|
|
# only add full subjects to top-level
|
|
if not _is_subject_reference(n):
|
|
graph.append(n)
|
|
# only add full subjects to top-level
|
|
if not _is_subject_reference(node):
|
|
result.append(node)
|
|
|
|
return result
|
|
|
|
def _process_context(self, active_ctx, local_ctx, options):
|
|
"""
|
|
Processes a local context and returns a new active context.
|
|
|
|
:param active_ctx: the current active context.
|
|
:param local_ctx: the local context to process.
|
|
:param options: the context processing options.
|
|
|
|
:return: the new active context.
|
|
"""
|
|
global _cache
|
|
|
|
# normalize local context to an array
|
|
if _is_object(local_ctx) and _is_array(local_ctx.get('@context')):
|
|
local_ctx = local_ctx['@context']
|
|
ctxs = JsonLdProcessor.arrayify(local_ctx)
|
|
|
|
# no contexts in array, clone existing context
|
|
if len(ctxs) == 0:
|
|
return self._clone_active_context(active_ctx)
|
|
|
|
# process each context in order, update active context on each
|
|
# iteration to ensure proper caching
|
|
rval = active_ctx
|
|
for ctx in ctxs:
|
|
# reset to initial context
|
|
if ctx is None:
|
|
rval = active_ctx = self._get_initial_context(options)
|
|
continue
|
|
|
|
# dereference @context key if present
|
|
if _is_object(ctx) and '@context' in ctx:
|
|
ctx = ctx['@context']
|
|
|
|
# context must be an object now, all URLs retrieved prior to call
|
|
if not _is_object(ctx):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context must be an object.',
|
|
'jsonld.SyntaxError', {'context': ctx},
|
|
code='invalid local context')
|
|
|
|
# get context from cache if available
|
|
if _cache.get('activeCtx') is not None:
|
|
cached = _cache['activeCtx'].get(active_ctx, ctx)
|
|
if cached:
|
|
rval = active_ctx = cached
|
|
continue
|
|
|
|
# update active context and clone new one before updating
|
|
active_ctx = rval
|
|
rval = self._clone_active_context(active_ctx)
|
|
|
|
# define context mappings for keys in local context
|
|
defined = {}
|
|
|
|
# handle @base
|
|
if '@base' in ctx:
|
|
base = ctx['@base']
|
|
if base is None:
|
|
base = None
|
|
elif not _is_string(base):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; the value of "@base" in a '
|
|
'@context must be a string or null.',
|
|
'jsonld.SyntaxError', {'context': ctx},
|
|
code='invalid base IRI')
|
|
elif base != '' and not _is_absolute_iri(base):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; the value of "@base" in a '
|
|
'@context must be an absolute IRI or the empty '
|
|
'string.', 'jsonld.SyntaxError', {'context': ctx},
|
|
code='invalid base IRI')
|
|
rval['@base'] = base
|
|
defined['@base'] = True
|
|
|
|
# handle @vocab
|
|
if '@vocab' in ctx:
|
|
value = ctx['@vocab']
|
|
if value is None:
|
|
del rval['@vocab']
|
|
elif not _is_string(value):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; the value of "@vocab" in a '
|
|
'@context must be a string or null.',
|
|
'jsonld.SyntaxError', {'context': ctx},
|
|
code='invalid vocab mapping')
|
|
elif not _is_absolute_iri(value):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; the value of "@vocab" in a '
|
|
'@context must be an absolute IRI.',
|
|
'jsonld.SyntaxError', {'context': ctx},
|
|
code='invalid vocab mapping')
|
|
else:
|
|
rval['@vocab'] = value
|
|
defined['@vocab'] = True
|
|
|
|
# handle @language
|
|
if '@language' in ctx:
|
|
value = ctx['@language']
|
|
if value is None:
|
|
del rval['@language']
|
|
elif not _is_string(value):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; the value of "@language" in '
|
|
'a @context must be a string or null.',
|
|
'jsonld.SyntaxError', {'context': ctx},
|
|
code='invalid default language')
|
|
else:
|
|
rval['@language'] = value.lower()
|
|
defined['@language'] = True
|
|
|
|
# process all other keys
|
|
for k, v in ctx.items():
|
|
self._create_term_definition(rval, ctx, k, defined)
|
|
|
|
# cache result
|
|
if _cache.get('activeCtx') is not None:
|
|
_cache.get('activeCtx').set(active_ctx, ctx, rval)
|
|
|
|
return rval
|
|
|
|
def _expand_language_map(self, language_map):
|
|
"""
|
|
Expands a language map.
|
|
|
|
:param language_map: the language map to expand.
|
|
|
|
:return: the expanded language map.
|
|
"""
|
|
rval = []
|
|
for key, values in sorted(language_map.items()):
|
|
values = JsonLdProcessor.arrayify(values)
|
|
for item in values:
|
|
if not _is_string(item):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; language map values must be '
|
|
'strings.', 'jsonld.SyntaxError',
|
|
{'languageMap': language_map},
|
|
code='invalid language map value')
|
|
rval.append({'@value': item, '@language': key.lower()})
|
|
return rval
|
|
|
|
def _expand_value(self, active_ctx, active_property, value):
|
|
"""
|
|
Expands the given value by using the coercion and keyword rules in the
|
|
given context.
|
|
|
|
:param active_ctx: the active context to use.
|
|
:param active_property: the property the value is associated with.
|
|
:param value: the value to expand.
|
|
|
|
:return: the expanded value.
|
|
"""
|
|
# nothing to expand
|
|
if value is None:
|
|
return None
|
|
|
|
# special-case expand @id and @type (skips '@id' expansion)
|
|
expanded_property = self._expand_iri(
|
|
active_ctx, active_property, vocab=True)
|
|
if expanded_property == '@id':
|
|
return self._expand_iri(active_ctx, value, base=True)
|
|
elif expanded_property == '@type':
|
|
return self._expand_iri(active_ctx, value, vocab=True, base=True)
|
|
|
|
# get type definition from context
|
|
type_ = JsonLdProcessor.get_context_value(
|
|
active_ctx, active_property, '@type')
|
|
|
|
# do @id expansion (automatic for @graph)
|
|
if (type_ == '@id' or (expanded_property == '@graph'
|
|
and _is_string(value))):
|
|
return {'@id': self._expand_iri(active_ctx, value, base=True)}
|
|
# do @id expansion w/vocab
|
|
if type_ == '@vocab':
|
|
return {'@id': self._expand_iri(
|
|
active_ctx, value, vocab=True, base=True)}
|
|
|
|
# do not expand keyword values
|
|
if _is_keyword(expanded_property):
|
|
return value
|
|
|
|
rval = {}
|
|
|
|
# other type
|
|
if type_ is not None:
|
|
rval['@type'] = type_
|
|
# check for language tagging
|
|
elif _is_string(value):
|
|
language = JsonLdProcessor.get_context_value(
|
|
active_ctx, active_property, '@language')
|
|
if language is not None:
|
|
rval['@language'] = language
|
|
rval['@value'] = value
|
|
|
|
return rval
|
|
|
|
def _graph_to_rdf(self, graph, namer, options):
|
|
"""
|
|
Creates an array of RDF triples for the given graph.
|
|
|
|
:param graph: the graph to create RDF triples for.
|
|
:param namer: the UniqueNamer for assigning blank node names.
|
|
:param options: the RDF serialization options.
|
|
|
|
:return: the array of RDF triples for the given graph.
|
|
"""
|
|
rval = []
|
|
for id_, node in sorted(graph.items()):
|
|
for property, items in sorted(node.items()):
|
|
if property == '@type':
|
|
property = RDF_TYPE
|
|
elif _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)):
|
|
continue
|
|
|
|
# RDF subject
|
|
subject = {}
|
|
if id_.startswith('_:'):
|
|
subject['type'] = 'blank node'
|
|
else:
|
|
subject['type'] = 'IRI'
|
|
subject['value'] = 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
|
|
|
|
# convert @list to triples
|
|
if _is_list(item):
|
|
self._list_to_rdf(
|
|
item['@list'], namer, subject, predicate, rval)
|
|
# convert value or node object to triple
|
|
else:
|
|
object = self._object_to_rdf(item)
|
|
# skip None objects (they are relative IRIs)
|
|
if object is not None:
|
|
rval.append({
|
|
'subject': subject,
|
|
'predicate': predicate,
|
|
'object': object
|
|
})
|
|
return rval
|
|
|
|
def _list_to_rdf(self, list, namer, subject, predicate, triples):
|
|
"""
|
|
Converts a @list value into a linked list of blank node RDF triples
|
|
(and RDF collection).
|
|
|
|
:param list: the @list value.
|
|
:param namer: the UniqueNamer for assigning blank node names.
|
|
:param subject: the subject for the head of the list.
|
|
:param predicate: the predicate for the head of the list.
|
|
:param triples: the array of triples to append to.
|
|
"""
|
|
first = {'type': 'IRI', 'value': RDF_FIRST}
|
|
rest = {'type': 'IRI', 'value': RDF_REST}
|
|
nil = {'type': 'IRI', 'value': RDF_NIL}
|
|
|
|
for item in list:
|
|
blank_node = {'type': 'blank node', 'value': namer.get_name()}
|
|
triples.append({
|
|
'subject': subject,
|
|
'predicate': predicate,
|
|
'object': blank_node
|
|
})
|
|
|
|
subject = blank_node
|
|
predicate = first
|
|
object = self._object_to_rdf(item)
|
|
# skip None objects (they are relative IRIs)
|
|
if object is not None:
|
|
triples.append({
|
|
'subject': subject,
|
|
'predicate': predicate,
|
|
'object': object
|
|
})
|
|
|
|
predicate = rest
|
|
|
|
triples.append({
|
|
'subject': subject,
|
|
'predicate': predicate,
|
|
'object': nil
|
|
})
|
|
|
|
def _object_to_rdf(self, item):
|
|
"""
|
|
Converts a JSON-LD value object to an RDF literal or a JSON-LD string
|
|
or node object to an RDF resource.
|
|
|
|
:param item: the JSON-LD value or node object.
|
|
|
|
:return: the RDF literal or RDF resource.
|
|
"""
|
|
object = {}
|
|
|
|
if _is_value(item):
|
|
object['type'] = 'literal'
|
|
value = item['@value']
|
|
datatype = item.get('@type')
|
|
|
|
# convert to XSD datatypes as appropriate
|
|
if _is_bool(value):
|
|
object['value'] = 'true' if value else 'false'
|
|
object['datatype'] = datatype or XSD_BOOLEAN
|
|
elif _is_double(value) or datatype == XSD_DOUBLE:
|
|
# canonical double representation
|
|
object['value'] = re.sub(r'(\d)0*E\+?0*(\d)', r'\1E\2',
|
|
('%1.15E' % value))
|
|
object['datatype'] = datatype or XSD_DOUBLE
|
|
elif _is_integer(value):
|
|
object['value'] = str(value)
|
|
object['datatype'] = datatype or XSD_INTEGER
|
|
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
|
|
# 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']):
|
|
return None
|
|
|
|
return object
|
|
|
|
def _rdf_to_object(self, o, use_native_types):
|
|
"""
|
|
Converts an RDF triple object to a JSON-LD object.
|
|
|
|
:param o: the RDF triple object to convert.
|
|
:param use_native_types: True to output native types, False not to.
|
|
|
|
: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']}
|
|
|
|
# convert literal object to JSON-LD
|
|
rval = {'@value': o['value']}
|
|
|
|
# add language
|
|
if 'language' in o:
|
|
rval['@language'] = o['language']
|
|
# add datatype
|
|
else:
|
|
type_ = o['datatype']
|
|
# use native types for certain xsd types
|
|
if use_native_types:
|
|
if type_ == XSD_BOOLEAN:
|
|
if rval['@value'] == 'true':
|
|
rval['@value'] = True
|
|
elif rval['@value'] == 'false':
|
|
rval['@value'] = False
|
|
elif _is_numeric(rval['@value']):
|
|
if type_ == XSD_INTEGER:
|
|
if rval['@value'].isdigit():
|
|
rval['@value'] = int(rval['@value'])
|
|
elif type_ == XSD_DOUBLE:
|
|
rval['@value'] = float(rval['@value'])
|
|
# do not add native type
|
|
if type_ not in [XSD_BOOLEAN, XSD_INTEGER, XSD_DOUBLE,
|
|
XSD_STRING]:
|
|
rval['@type'] = type_
|
|
elif type_ != XSD_STRING:
|
|
rval['@type'] = type_
|
|
return rval
|
|
|
|
def _create_node_map(
|
|
self, input_, graphs, graph, namer, name=None, list_=None):
|
|
"""
|
|
Recursively flattens the subjects in the given JSON-LD expanded
|
|
input into a node map.
|
|
|
|
:param input_: the JSON-LD expanded input.
|
|
:param graphs: a map of graph name to subject map.
|
|
:param graph: the name of the current graph.
|
|
:param namer: the UniqueNamer for assigning blank node names.
|
|
:param name: the name assigned to the current input if it is a bnode.
|
|
:param list_: the list to append to, None for none.
|
|
"""
|
|
# recurse through array
|
|
if _is_array(input_):
|
|
for e in input_:
|
|
self._create_node_map(e, graphs, graph, namer, None, list_)
|
|
return
|
|
|
|
# add non-object to list
|
|
if not _is_object(input_):
|
|
if list_ is not None:
|
|
list_.append(input_)
|
|
return
|
|
|
|
# add values to list
|
|
if _is_value(input_):
|
|
if '@type' in input_:
|
|
type_ = input_['@type']
|
|
# rename @type blank node
|
|
if type_.startswith('_:'):
|
|
type_ = input_['@type'] = namer.get_name(type_)
|
|
if list_ is not None:
|
|
list_.append(input_)
|
|
return
|
|
|
|
# Note: At this point, input must be a subject.
|
|
|
|
# spec requires @type to be named first, so assign names early
|
|
if '@type' in input_:
|
|
for type_ in input_['@type']:
|
|
if type_.startswith('_:'):
|
|
namer.get_name(type_)
|
|
|
|
# get name for subject
|
|
if name is None:
|
|
name = input_.get('@id')
|
|
if _is_bnode(input_):
|
|
name = namer.get_name(name)
|
|
|
|
# add subject reference to list
|
|
if list_ is not None:
|
|
list_.append({'@id': name})
|
|
|
|
# create new subject or merge into existing one
|
|
subject = graphs.setdefault(graph, {}).setdefault(name, {'@id': name})
|
|
for property, objects in sorted(input_.items()):
|
|
# skip @id
|
|
if property == '@id':
|
|
continue
|
|
|
|
# handle reverse properties
|
|
if property == '@reverse':
|
|
referenced_node = {'@id': name}
|
|
reverse_map = input_['@reverse']
|
|
for reverse_property, items in reverse_map.items():
|
|
for item in items:
|
|
item_name = item.get('@id')
|
|
if _is_bnode(item):
|
|
item_name = namer.get_name(item_name)
|
|
self._create_node_map(
|
|
item, graphs, graph, namer, item_name)
|
|
JsonLdProcessor.add_value(
|
|
graphs[graph][item_name], reverse_property,
|
|
referenced_node,
|
|
{'propertyIsArray': True, 'allowDuplicate': False})
|
|
continue
|
|
|
|
# recurse into graph
|
|
if property == '@graph':
|
|
# add graph subjects map entry
|
|
graphs.setdefault(name, {})
|
|
g = graph if graph == '@merged' else name
|
|
self._create_node_map(objects, graphs, g, namer)
|
|
continue
|
|
|
|
# copy non-@type keywords
|
|
if property != '@type' and _is_keyword(property):
|
|
if property == '@index' and '@index' in subject \
|
|
and (input_['@index'] != subject['@index'] or
|
|
input_['@index']['@id'] != subject['@index']['@id']):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; conflicting @index property '
|
|
' detected.', 'jsonld.SyntaxError',
|
|
{'subject': subject}, code='conflicting indexes')
|
|
subject[property] = input_[property]
|
|
continue
|
|
|
|
# if property is a bnode, assign it a new id
|
|
if property.startswith('_:'):
|
|
property = namer.get_name(property)
|
|
|
|
# ensure property is added for empty arrays
|
|
if len(objects) == 0:
|
|
JsonLdProcessor.add_value(
|
|
subject, property, [], {'propertyIsArray': True})
|
|
continue
|
|
|
|
for o in objects:
|
|
if property == '@type':
|
|
# rename @type blank nodes
|
|
o = namer.get_name(o) if o.startswith('_:') else o
|
|
|
|
# handle embedded subject or subject reference
|
|
if _is_subject(o) or _is_subject_reference(o):
|
|
# rename blank node @id
|
|
id_ = o.get('@id')
|
|
if _is_bnode(o):
|
|
id_ = namer.get_name(id_)
|
|
|
|
# add reference and recurse
|
|
JsonLdProcessor.add_value(
|
|
subject, property, {'@id': id_},
|
|
{'propertyIsArray': True, 'allowDuplicate': False})
|
|
self._create_node_map(o, graphs, graph, namer, id_)
|
|
# handle @list
|
|
elif _is_list(o):
|
|
olist = []
|
|
self._create_node_map(
|
|
o['@list'], graphs, graph, namer, name, olist)
|
|
o = {'@list': olist}
|
|
JsonLdProcessor.add_value(
|
|
subject, property, o,
|
|
{'propertyIsArray': True, 'allowDuplicate': False})
|
|
# handle @value
|
|
else:
|
|
self._create_node_map(o, graphs, graph, namer, name)
|
|
JsonLdProcessor.add_value(
|
|
subject, property, o,
|
|
{'propertyIsArray': True, 'allowDuplicate': False})
|
|
|
|
def _match_frame(self, state, subjects, frame, parent, property):
|
|
"""
|
|
Frames subjects according to the given frame.
|
|
|
|
:param state: the current framing state.
|
|
:param subjects: the subjects to filter.
|
|
:param frame: the frame.
|
|
:param parent: the parent subject or top-level array.
|
|
:param property: the parent property, initialized to None.
|
|
"""
|
|
# validate the frame
|
|
self._validate_frame(frame)
|
|
frame = frame[0]
|
|
|
|
# get flags for current frame
|
|
options = state['options']
|
|
flags = {
|
|
'embed': self._get_frame_flag(frame, options, 'embed'),
|
|
'explicit': self._get_frame_flag(frame, options, 'explicit'),
|
|
'requireAll': self._get_frame_flag(frame, options, 'requireAll')
|
|
}
|
|
|
|
# filter out subjects that match the frame
|
|
matches = self._filter_subjects(state, subjects, frame, flags)
|
|
|
|
# add matches to output
|
|
for id_, subject in sorted(matches.items()):
|
|
if flags['embed'] == '@link' and id_ in state['link']:
|
|
# TODO: may want to also match an existing linked subject
|
|
# against the current frame ... so different frames could
|
|
# produce different subjects that are only shared in-memory
|
|
# when the frames are the same
|
|
|
|
# add existing linked subject
|
|
self._add_frame_output(parent, property, state['link'][id_])
|
|
continue
|
|
|
|
# Note: In order to treat each top-level match as a
|
|
# compartmentalized result, clear the unique embedded subjects map
|
|
# when the property is None, which only occurs at the top-level.
|
|
if property is None:
|
|
state['uniqueEmbeds'] = {}
|
|
|
|
# start output for subject
|
|
output = {'@id': id_}
|
|
state['link'][id_] = output
|
|
|
|
# if embed is @never or if a circular reference would be created
|
|
# by an embed, the subject cannot be embedded, just add the
|
|
# reference; note that a circular reference won't occur when the
|
|
# embed flag is `@link` as the above check will short-circuit
|
|
# before reaching this point
|
|
if(flags['embed'] == '@never' or self._creates_circular_reference(
|
|
subject, state['subjectStack'])):
|
|
self._add_frame_output(parent, property, output)
|
|
continue
|
|
|
|
# if only the last match should be embedded
|
|
if flags['embed'] == '@last':
|
|
# remove any existing embed
|
|
if id_ in state['uniqueEmbeds']:
|
|
self._remove_embed(state, id_)
|
|
state['uniqueEmbeds'][id_] = {
|
|
'parent': parent,
|
|
'property': property
|
|
}
|
|
|
|
# push matching subject onto stack to enable circular embed checks
|
|
state['subjectStack'].append(subject)
|
|
|
|
# iterate over subject properties in order
|
|
for prop, objects in sorted(subject.items()):
|
|
# copy keywords to output
|
|
if _is_keyword(prop):
|
|
output[prop] = copy.deepcopy(subject[prop])
|
|
continue
|
|
|
|
# explicit is on and property isn't in frame, skip processing
|
|
if flags['explicit'] and prop not in frame:
|
|
continue
|
|
|
|
# add objects
|
|
objects = subject[prop]
|
|
for o in objects:
|
|
# recurse into list
|
|
if _is_list(o):
|
|
# add empty list
|
|
list_ = {'@list': []}
|
|
self._add_frame_output(output, prop, list_)
|
|
|
|
# add list objects
|
|
src = o['@list']
|
|
for o in src:
|
|
if _is_subject_reference(o):
|
|
# recurse into subject reference
|
|
if prop in frame:
|
|
subframe = frame[prop][0]['@list']
|
|
else:
|
|
subframe = self._create_implicit_frame(
|
|
flags)
|
|
self._match_frame(
|
|
state, [o['@id']],
|
|
subframe, list_, '@list')
|
|
else:
|
|
# include other values automatically
|
|
self._add_frame_output(
|
|
list_, '@list', copy.deepcopy(o))
|
|
continue
|
|
|
|
if _is_subject_reference(o):
|
|
# recurse into subject reference
|
|
if prop in frame:
|
|
subframe = frame[prop]
|
|
else:
|
|
subframe = self._create_implicit_frame(flags)
|
|
self._match_frame(
|
|
state, [o['@id']], subframe, output, prop)
|
|
else:
|
|
# include other values automatically
|
|
self._add_frame_output(output, prop, copy.deepcopy(o))
|
|
|
|
# handle defaults in order
|
|
for prop in sorted(frame.keys()):
|
|
# skip keywords
|
|
if _is_keyword(prop):
|
|
continue
|
|
# if omit default is off, then include default values for
|
|
# properties that appear in the next frame but are not in
|
|
# the matching subject
|
|
next = frame[prop][0]
|
|
omit_default_on = self._get_frame_flag(
|
|
next, options, 'omitDefault')
|
|
if not omit_default_on and prop not in output:
|
|
preserve = '@null'
|
|
if '@default' in next:
|
|
preserve = copy.deepcopy(next['@default'])
|
|
preserve = JsonLdProcessor.arrayify(preserve)
|
|
output[prop] = [{'@preserve': preserve}]
|
|
|
|
# add output to parent
|
|
self._add_frame_output(parent, property, output)
|
|
|
|
# pop matching subject from circular ref-checking stack
|
|
state['subjectStack'].pop()
|
|
|
|
def _create_implicit_frame(self, flags):
|
|
"""
|
|
Creates an implicit frame when recursing through subject matches. If
|
|
a frame doesn't have an explicit frame for a particular property, then
|
|
a wildcard child frame will be created that uses the same flags that
|
|
the parent frame used.
|
|
|
|
:param flags: the current framing flags.
|
|
|
|
:return: the implicit frame.
|
|
"""
|
|
frame = {}
|
|
for key in flags:
|
|
frame['@' + key] = [flags[key]]
|
|
return [frame]
|
|
|
|
def _creates_circular_reference(self, subject_to_embed, subject_stack):
|
|
"""
|
|
Checks the current subject stack to see if embedding the given subject
|
|
would cause a circular reference.
|
|
|
|
:param subject_to_embed: the subject to embed.
|
|
:param subject_stack: the current stack of subjects.
|
|
|
|
:return: true if a circular reference would be created, false if not.
|
|
"""
|
|
for subject in reversed(subject_stack[:-1]):
|
|
if subject['@id'] == subject_to_embed['@id']:
|
|
return True
|
|
return False
|
|
|
|
def _get_frame_flag(self, frame, options, name):
|
|
"""
|
|
Gets the frame flag value for the given flag name.
|
|
|
|
:param frame: the frame.
|
|
:param options: the framing options.
|
|
:param name: the flag name.
|
|
|
|
:return: the flag value.
|
|
"""
|
|
rval = frame.get('@' + name, [options[name]])[0]
|
|
if name == 'embed':
|
|
# default is "@last"
|
|
# backwards-compatibility support for "embed" maps:
|
|
# true => "@last"
|
|
# false => "@never"
|
|
if rval is True:
|
|
rval = '@last'
|
|
elif rval is False:
|
|
rval = '@never'
|
|
elif rval != '@always' and rval != '@never' and rval != '@link':
|
|
rval = '@last'
|
|
return rval
|
|
|
|
def _validate_frame(self, frame):
|
|
"""
|
|
Validates a JSON-LD frame, throwing an exception if the frame is
|
|
invalid.
|
|
|
|
:param frame: the frame to validate.
|
|
"""
|
|
if (not _is_array(frame) or len(frame) != 1 or
|
|
not _is_object(frame[0])):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; a JSON-LD frame must be a single '
|
|
'object.', 'jsonld.SyntaxError', {'frame': frame})
|
|
|
|
def _filter_subjects(self, state, subjects, frame, flags):
|
|
"""
|
|
Returns a map of all of the subjects that match a parsed frame.
|
|
|
|
:param state: the current framing state.
|
|
:param subjects: the set of subjects to filter.
|
|
:param frame: the parsed frame.
|
|
:param flags: the frame flags.
|
|
|
|
:return: all of the matched subjects.
|
|
"""
|
|
rval = {}
|
|
for id_ in subjects:
|
|
subject = state['subjects'][id_]
|
|
if self._filter_subject(subject, frame, flags):
|
|
rval[id_] = subject
|
|
return rval
|
|
|
|
def _filter_subject(self, subject, frame, flags):
|
|
"""
|
|
Returns True if the given subject matches the given frame.
|
|
|
|
:param subject: the subject to check.
|
|
:param frame: the frame to check.
|
|
:param flags: the frame flags.
|
|
|
|
:return: True if the subject matches, False if not.
|
|
"""
|
|
# check @type (object value means 'any' type, fall through to
|
|
# ducktyping)
|
|
if ('@type' in frame and
|
|
not (len(frame['@type']) == 1 and
|
|
_is_object(frame['@type'][0]))):
|
|
types = frame['@type']
|
|
for t in types:
|
|
# any matching @type is a match
|
|
if JsonLdProcessor.has_value(subject, '@type', t):
|
|
return True
|
|
return False
|
|
|
|
# check ducktype
|
|
wildcard = True
|
|
matches_some = False
|
|
for k, v in frame.items():
|
|
if _is_keyword(k):
|
|
# skip non-@id and non-@type
|
|
if k != '@id' and k != '@type':
|
|
continue
|
|
wildcard = True
|
|
|
|
# check @id for a specific @id value
|
|
if k == '@id' and _is_string(v):
|
|
if subject.get(k) != v:
|
|
return False
|
|
|
|
matches_some = True
|
|
continue
|
|
|
|
wildcard = False
|
|
|
|
if k in subject:
|
|
# v == [] means do not match if property is present
|
|
if _is_array(v) and len(v) == 0:
|
|
return False
|
|
|
|
matches_some = True
|
|
continue
|
|
|
|
# all properties must match to be a duck unless a @default is
|
|
# specified
|
|
has_default = (_is_array(v) and len(v) == 1 and
|
|
_is_object(v[0]) and '@default' in v[0])
|
|
if flags['requireAll'] and not has_default:
|
|
return False
|
|
|
|
# return true if wildcard or subject matches some properties
|
|
return wildcard or matches_some
|
|
|
|
def _remove_embed(self, state, id_):
|
|
"""
|
|
Removes an existing embed.
|
|
|
|
:param state: the current framing state.
|
|
:param id_: the @id of the embed to remove.
|
|
"""
|
|
# get existing embed
|
|
embeds = state['uniqueEmbeds']
|
|
embed = embeds[id_]
|
|
property = embed['property']
|
|
|
|
# create reference to replace embed
|
|
subject = {'@id': id_}
|
|
|
|
# remove existing embed
|
|
if _is_array(embed['parent']):
|
|
# replace subject with reference
|
|
for i, parent in enumerate(embed['parent']):
|
|
if JsonLdProcessor.compare_values(parent, subject):
|
|
embed['parent'][i] = subject
|
|
break
|
|
else:
|
|
# replace subject with reference
|
|
use_array = _is_array(embed['parent'][property])
|
|
JsonLdProcessor.remove_value(
|
|
embed['parent'], property, subject,
|
|
{'propertyIsArray': use_array})
|
|
JsonLdProcessor.add_value(
|
|
embed['parent'], property, subject,
|
|
{'propertyIsArray': use_array})
|
|
|
|
# recursively remove dependent dangling embeds
|
|
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:
|
|
if (next in embeds and
|
|
_is_object(embeds[next]['parent']) and
|
|
embeds[next]['parent']['@id'] == id_):
|
|
del embeds[next]
|
|
remove_dependents(next)
|
|
remove_dependents(id_)
|
|
|
|
def _add_frame_output(self, parent, property, output):
|
|
"""
|
|
Adds framing output to the given parent.
|
|
|
|
:param parent: the parent to add to.
|
|
:param property: the parent property.
|
|
:param output: the output to add.
|
|
"""
|
|
if _is_object(parent):
|
|
JsonLdProcessor.add_value(
|
|
parent, property, output, {'propertyIsArray': True})
|
|
else:
|
|
parent.append(output)
|
|
|
|
def _remove_preserve(self, ctx, input_, options):
|
|
"""
|
|
Removes the @preserve keywords as the last step of the framing
|
|
algorithm.
|
|
|
|
:param ctx: the active context used to compact the input.
|
|
:param input_: the framed, compacted output.
|
|
:param options: the compaction options used.
|
|
|
|
:return: the resulting output.
|
|
"""
|
|
# recurse through arrays
|
|
if _is_array(input_):
|
|
output = []
|
|
for e in input_:
|
|
result = self._remove_preserve(ctx, e, options)
|
|
# drop Nones from arrays
|
|
if result is not None:
|
|
output.append(result)
|
|
return output
|
|
elif _is_object(input_):
|
|
# remove @preserve
|
|
if '@preserve' in input_:
|
|
if input_['@preserve'] == '@null':
|
|
return None
|
|
return input_['@preserve']
|
|
|
|
# skip @values
|
|
if _is_value(input_):
|
|
return input_
|
|
|
|
# recurse through @lists
|
|
if _is_list(input_):
|
|
input_['@list'] = self._remove_preserve(
|
|
ctx, input_['@list'], options)
|
|
return input_
|
|
|
|
# handle in-memory linked nodes
|
|
id_alias = self._compact_iri(ctx, '@id')
|
|
if id_alias in input_:
|
|
id_ = input_[id_alias]
|
|
if id_ in options['link']:
|
|
try:
|
|
idx = options['link'][id_].index(input_)
|
|
# already visited
|
|
return options['link'][id_][idx]
|
|
except BaseException:
|
|
# prevent circular visitation
|
|
options['link'][id_].append(input_)
|
|
else:
|
|
# prevent circular visitation
|
|
options['link'][id_] = [input_]
|
|
|
|
# recurse through properties
|
|
for prop, v in input_.items():
|
|
result = self._remove_preserve(ctx, v, options)
|
|
container = JsonLdProcessor.get_context_value(
|
|
ctx, prop, '@container')
|
|
if (options['compactArrays'] and
|
|
_is_array(result) and len(result) == 1 and
|
|
container != '@set' and container != '@list'):
|
|
result = result[0]
|
|
input_[prop] = result
|
|
return input_
|
|
|
|
def _hash_quads(self, id_, bnodes):
|
|
"""
|
|
Hashes all of the quads about a blank node.
|
|
|
|
:param id_: the ID of the bnode to hash quads for.
|
|
:param bnodes: the mapping of bnodes to quads.
|
|
:param namer: the canonical bnode namer.
|
|
|
|
:return: the new hash.
|
|
"""
|
|
# return cached hash
|
|
if 'hash' in bnodes[id_]:
|
|
return bnodes[id_]['hash']
|
|
|
|
# serialize all of bnode's quads
|
|
quads = bnodes[id_]['quads']
|
|
nquads = []
|
|
for quad in quads:
|
|
nquads.append(JsonLdProcessor.to_nquad(
|
|
quad, quad['name']['value'] if 'name' in quad else None, id_))
|
|
# sort serialized quads
|
|
nquads.sort()
|
|
# cache and return hashed quads
|
|
md = hashlib.sha1()
|
|
md.update(''.join(nquads).encode('utf-8'))
|
|
hash = bnodes[id_]['hash'] = md.hexdigest()
|
|
return hash
|
|
|
|
def _hash_paths(self, id_, bnodes, namer, path_namer):
|
|
"""
|
|
Produces a hash for the paths of adjacent bnodes for a bnode,
|
|
incorporating all information about its subgraph of bnodes. This
|
|
method will recursively pick adjacent bnode permutations that produce
|
|
the lexicographically-least 'path' serializations.
|
|
|
|
:param id_: the ID of the bnode to hash paths for.
|
|
:param bnodes: the map of bnode quads.
|
|
:param namer: the canonical bnode namer.
|
|
:param path_namer: the namer used to assign names to adjacent bnodes.
|
|
|
|
:return: the hash and path namer used.
|
|
"""
|
|
# create SHA-1 digest
|
|
md = hashlib.sha1()
|
|
|
|
# group adjacent bnodes by hash, keep properties & references separate
|
|
groups = {}
|
|
quads = bnodes[id_]['quads']
|
|
for quad in quads:
|
|
# get adjacent bnode
|
|
bnode = self._get_adjacent_bnode_name(quad['subject'], id_)
|
|
if bnode is not None:
|
|
# normal property
|
|
direction = 'p'
|
|
else:
|
|
bnode = self._get_adjacent_bnode_name(quad['object'], id_)
|
|
if bnode is None:
|
|
continue
|
|
# reference property
|
|
direction = 'r'
|
|
|
|
# get bnode name (try canonical, path, then hash)
|
|
if namer.is_named(bnode):
|
|
name = namer.get_name(bnode)
|
|
elif path_namer.is_named(bnode):
|
|
name = path_namer.get_name(bnode)
|
|
else:
|
|
name = self._hash_quads(bnode, bnodes)
|
|
|
|
# hash direction, property, and bnode name/hash
|
|
group_md = hashlib.sha1()
|
|
group_md.update(direction.encode('utf-8'))
|
|
group_md.update(quad['predicate']['value'].encode('utf-8'))
|
|
group_md.update(name.encode('utf-8'))
|
|
group_hash = group_md.hexdigest()
|
|
|
|
# add bnode to hash group
|
|
groups.setdefault(group_hash, []).append(bnode)
|
|
|
|
# iterate over groups in sorted hash order
|
|
for group_hash, group in sorted(groups.items()):
|
|
# digest group hash
|
|
md.update(group_hash.encode('utf8'))
|
|
|
|
# choose a path and namer from the permutations
|
|
chosen_path = None
|
|
chosen_namer = None
|
|
for permutation in permutations(group):
|
|
path_namer_copy = copy.deepcopy(path_namer)
|
|
|
|
# build adjacent path
|
|
path = ''
|
|
skipped = False
|
|
recurse = []
|
|
for bnode in permutation:
|
|
# use canonical name if available
|
|
if namer.is_named(bnode):
|
|
path += namer.get_name(bnode)
|
|
else:
|
|
# recurse if bnode isn't named in the path yet
|
|
if not path_namer_copy.is_named(bnode):
|
|
recurse.append(bnode)
|
|
path += path_namer_copy.get_name(bnode)
|
|
|
|
# skip permutation if path is already >= chosen path
|
|
if (chosen_path is not None and
|
|
len(path) >= len(chosen_path) and
|
|
path > chosen_path):
|
|
skipped = True
|
|
break
|
|
|
|
# recurse
|
|
if not skipped:
|
|
for bnode in recurse:
|
|
result = self._hash_paths(
|
|
bnode, bnodes, namer, path_namer_copy)
|
|
path += path_namer_copy.get_name(bnode)
|
|
path += '<%s>' % result['hash']
|
|
path_namer_copy = result['pathNamer']
|
|
|
|
# skip permutation if path is already >= chosen path
|
|
if (chosen_path is not None and
|
|
len(path) >= len(chosen_path) and
|
|
path > chosen_path):
|
|
skipped = True
|
|
break
|
|
|
|
if (not skipped and
|
|
(chosen_path is None or path < chosen_path)):
|
|
chosen_path = path
|
|
chosen_namer = path_namer_copy
|
|
|
|
# digest chosen path and update namer
|
|
md.update(chosen_path.encode('utf-8'))
|
|
path_namer = chosen_namer
|
|
|
|
# return SHA-1 hash and path namer
|
|
return {'hash': md.hexdigest(), 'pathNamer': path_namer}
|
|
|
|
def _get_adjacent_bnode_name(self, node, id_):
|
|
"""
|
|
A helper function that gets the blank node name from an RDF quad
|
|
node (subject or object). If the node is not a blank node or its
|
|
value does not match the given blank node ID, it will be returned.
|
|
|
|
:param node: the RDF quad node.
|
|
:param id_: the ID of the blank node to look next to.
|
|
|
|
:return: the adjacent blank node name or None if none was found.
|
|
"""
|
|
if node['type'] == 'blank node' and node['value'] != id_:
|
|
return node['value']
|
|
return None
|
|
|
|
def _select_term(
|
|
self, active_ctx, iri, value, containers,
|
|
type_or_language, type_or_language_value):
|
|
"""
|
|
Picks the preferred compaction term from the inverse context entry.
|
|
|
|
:param active_ctx: the active context.
|
|
:param iri: the IRI to pick the term for.
|
|
:param value: the value to pick the term for.
|
|
:param containers: the preferred containers.
|
|
:param type_or_language: either '@type' or '@language'.
|
|
:param type_or_language_value: the preferred value for '@type' or
|
|
'@language'
|
|
|
|
:return: the preferred term.
|
|
"""
|
|
if type_or_language_value is None:
|
|
type_or_language_value = '@null'
|
|
|
|
# preferred options for the value of @type or language
|
|
prefs = []
|
|
|
|
# determine prefs for @id based on whether value compacts to term
|
|
if ((type_or_language_value == '@id' or
|
|
type_or_language_value == '@reverse') and
|
|
_is_subject_reference(value)):
|
|
# prefer @reverse first
|
|
if type_or_language_value == '@reverse':
|
|
prefs.append('@reverse')
|
|
# try to compact value to a term
|
|
term = self._compact_iri(
|
|
active_ctx, value['@id'], None, vocab=True)
|
|
mapping = active_ctx['mappings'].get(term)
|
|
if term is not None and mapping and mapping['@id'] == value['@id']:
|
|
# prefer @vocab
|
|
prefs.extend(['@vocab', '@id'])
|
|
else:
|
|
# prefer @id
|
|
prefs.extend(['@id', '@vocab'])
|
|
else:
|
|
prefs.append(type_or_language_value)
|
|
prefs.append('@none')
|
|
|
|
container_map = active_ctx['inverse'][iri]
|
|
for container in containers:
|
|
# skip container if not in map
|
|
if container not in container_map:
|
|
continue
|
|
type_or_language_value_map = (
|
|
container_map[container][type_or_language])
|
|
for pref in prefs:
|
|
# skip type/language preference if not in map
|
|
if pref not in type_or_language_value_map:
|
|
continue
|
|
return type_or_language_value_map[pref]
|
|
return None
|
|
|
|
def _compact_iri(
|
|
self, active_ctx, iri, value=None, vocab=False, reverse=False):
|
|
"""
|
|
Compacts an IRI or keyword into a term or CURIE if it can be. If the
|
|
IRI has an associated value it may be passed.
|
|
|
|
:param active_ctx: the active context to use.
|
|
:param iri: the IRI to compact.
|
|
:param value: the value to check or None.
|
|
:param vocab: True to compact using @vocab if available, False not to.
|
|
:param reverse: True if a reverse property is being compacted, False if
|
|
not.
|
|
|
|
:return: the compacted term, prefix, keyword alias, or original IRI.
|
|
"""
|
|
# can't compact None
|
|
if iri is None:
|
|
return iri
|
|
|
|
# term is a keyword, force vocab to True
|
|
if _is_keyword(iri):
|
|
vocab = True
|
|
|
|
# use inverse context to pick a term if iri is relative to vocab
|
|
if vocab and iri in self._get_inverse_context(active_ctx):
|
|
default_language = active_ctx.get('@language', '@none')
|
|
|
|
# prefer @index if available in value
|
|
containers = []
|
|
if _is_object(value) and '@index' in value:
|
|
containers.append('@index')
|
|
|
|
# defaults for term selection based on type/language
|
|
type_or_language = '@language'
|
|
type_or_language_value = '@null'
|
|
|
|
if reverse:
|
|
type_or_language = '@type'
|
|
type_or_language_value = '@reverse'
|
|
containers.append('@set')
|
|
# choose most specific term that works for all elements in @list
|
|
elif _is_list(value):
|
|
# only select @list containers if @index is NOT in value
|
|
if '@index' not in value:
|
|
containers.append('@list')
|
|
list_ = value['@list']
|
|
common_language = default_language if len(list_) == 0 else None
|
|
common_type = None
|
|
for item in list_:
|
|
item_language = '@none'
|
|
item_type = '@none'
|
|
if _is_value(item):
|
|
if '@language' in item:
|
|
item_language = item['@language']
|
|
elif '@type' in item:
|
|
item_type = item['@type']
|
|
# plain literal
|
|
else:
|
|
item_language = '@null'
|
|
else:
|
|
item_type = '@id'
|
|
if common_language is None:
|
|
common_language = item_language
|
|
elif item_language != common_language and _is_value(item):
|
|
common_language = '@none'
|
|
if common_type is None:
|
|
common_type = item_type
|
|
elif item_type != common_type:
|
|
common_type = '@none'
|
|
# there are different languages and types in the list, so
|
|
# choose the most generic term, no need to keep iterating
|
|
if common_language == '@none' and common_type == '@none':
|
|
break
|
|
if common_language is None:
|
|
common_language = '@none'
|
|
if common_type is None:
|
|
common_type = '@none'
|
|
if common_type != '@none':
|
|
type_or_language = '@type'
|
|
type_or_language_value = common_type
|
|
else:
|
|
type_or_language_value = common_language
|
|
# non-@list
|
|
else:
|
|
if _is_value(value):
|
|
if '@language' in value and '@index' not in value:
|
|
containers.append('@language')
|
|
type_or_language_value = value['@language']
|
|
elif '@type' in value:
|
|
type_or_language = '@type'
|
|
type_or_language_value = value['@type']
|
|
else:
|
|
type_or_language = '@type'
|
|
type_or_language_value = '@id'
|
|
containers.append('@set')
|
|
|
|
# do term selection
|
|
containers.append('@none')
|
|
term = self._select_term(
|
|
active_ctx, iri, value, containers,
|
|
type_or_language, type_or_language_value)
|
|
if term is not None:
|
|
return term
|
|
|
|
# no term match, use @vocab if available
|
|
if vocab:
|
|
if '@vocab' in active_ctx:
|
|
vocab_ = active_ctx['@vocab']
|
|
if iri.startswith(vocab_) and iri != vocab_:
|
|
# use suffix as relative iri if it is not a term in the
|
|
# active context
|
|
suffix = iri[len(vocab_):]
|
|
if suffix not in active_ctx['mappings']:
|
|
return suffix
|
|
|
|
# no term or @vocab match, check for possible CURIEs
|
|
candidate = None
|
|
for term, definition in active_ctx['mappings'].items():
|
|
# skip terms with colons, they can't be prefixes
|
|
if ':' in term:
|
|
continue
|
|
# skip entries with @ids that are not partial matches
|
|
if (definition is None or definition['@id'] == iri or
|
|
not iri.startswith(definition['@id'])):
|
|
continue
|
|
|
|
# a CURIE is usable if:
|
|
# 1. it has no mapping, OR
|
|
# 2. value is None, which means we're not compacting an @value, AND
|
|
# the mapping matches the IRI
|
|
curie = term + ':' + iri[len(definition['@id']):]
|
|
is_usable_curie = (
|
|
curie not in active_ctx['mappings'] or
|
|
(value is None and
|
|
active_ctx['mappings'].get(curie, {}).get('@id') == iri))
|
|
|
|
# select curie if it is shorter or the same length but
|
|
# lexicographically less than the current choice
|
|
if (is_usable_curie and (candidate is None or
|
|
_compare_shortest_least(curie,
|
|
candidate) < 0)):
|
|
candidate = curie
|
|
|
|
# return curie candidate
|
|
if candidate is not None:
|
|
return candidate
|
|
|
|
# compact IRI relative to base
|
|
if not vocab:
|
|
return remove_base(active_ctx['@base'], iri)
|
|
|
|
# return IRI as is
|
|
return iri
|
|
|
|
def _compact_value(self, active_ctx, active_property, value):
|
|
"""
|
|
Performs value compaction on an object with @value or @id as the only
|
|
property.
|
|
|
|
:param active_ctx: the active context.
|
|
:param active_property: the active property that points to the value.
|
|
:param value: the value to compact.
|
|
"""
|
|
if _is_value(value):
|
|
# get context rules
|
|
type_ = JsonLdProcessor.get_context_value(
|
|
active_ctx, active_property, '@type')
|
|
language = JsonLdProcessor.get_context_value(
|
|
active_ctx, active_property, '@language')
|
|
container = JsonLdProcessor.get_context_value(
|
|
active_ctx, active_property, '@container')
|
|
|
|
# whether or not the value has an @index that must be preserved
|
|
preserve_index = '@index' in value and container != '@index'
|
|
|
|
# if there's no @index to preserve
|
|
if not preserve_index:
|
|
# matching @type or @language specified in context, compact
|
|
if (('@type' in value and value['@type'] == type_) or
|
|
('@language' in value and
|
|
value['@language'] == language)):
|
|
return value['@value']
|
|
|
|
# return just the value of @value if all are true:
|
|
# 1. @value is the only key or @index isn't being preserved
|
|
# 2. there is no default language or @value is not a string or
|
|
# the key has a mapping with a null @language
|
|
key_count = len(value)
|
|
is_value_only_key = \
|
|
(key_count == 1 or (key_count == 2 and
|
|
'@index' in value and not preserve_index))
|
|
has_default_language = '@language' in active_ctx
|
|
is_value_string = _is_string(value['@value'])
|
|
has_null_mapping = (
|
|
active_ctx['mappings'].get(active_property) is not None and
|
|
'@language' in active_ctx['mappings'][active_property] and
|
|
active_ctx['mappings'][active_property]['@language'] is None)
|
|
if (is_value_only_key and (
|
|
not has_default_language or not is_value_string or
|
|
has_null_mapping)):
|
|
return value['@value']
|
|
|
|
rval = {}
|
|
|
|
# preserve @index
|
|
if preserve_index:
|
|
rval[self._compact_iri(active_ctx, '@index')] = value['@index']
|
|
|
|
# compact @type IRI
|
|
if '@type' in value:
|
|
rval[self._compact_iri(active_ctx, '@type')] = (
|
|
self._compact_iri(active_ctx, value['@type'], vocab=True))
|
|
# alias @language
|
|
elif '@language' in value:
|
|
rval[self._compact_iri(active_ctx, '@language')] = (
|
|
value['@language'])
|
|
|
|
# alias @value
|
|
rval[self._compact_iri(active_ctx, '@value')] = value['@value']
|
|
|
|
return rval
|
|
|
|
# value is a subject reference
|
|
expanded_property = self._expand_iri(
|
|
active_ctx, active_property, vocab=True)
|
|
type_ = JsonLdProcessor.get_context_value(
|
|
active_ctx, active_property, '@type')
|
|
compacted = self._compact_iri(
|
|
active_ctx, value['@id'], vocab=(type_ == '@vocab'))
|
|
|
|
# compact to scalar
|
|
if type_ in ['@id', '@vocab'] or expanded_property == '@graph':
|
|
return compacted
|
|
|
|
rval = {}
|
|
rval[self._compact_iri(active_ctx, '@id')] = compacted
|
|
return rval
|
|
|
|
def _create_term_definition(self, active_ctx, local_ctx, term, defined):
|
|
"""
|
|
Creates a term definition during context processing.
|
|
|
|
:param active_ctx: the current active context.
|
|
:param local_ctx: the local context being processed.
|
|
:param term: the key in the local context to define the mapping for.
|
|
:param defined: a map of defining/defined keys to detect cycles
|
|
and prevent double definitions.
|
|
"""
|
|
if term in defined:
|
|
# term already defined
|
|
if defined[term]:
|
|
return
|
|
# cycle detected
|
|
raise JsonLdError(
|
|
'Cyclical context definition detected.',
|
|
'jsonld.CyclicalContext', {
|
|
'context': local_ctx,
|
|
'term': term
|
|
}, code='cyclic IRI mapping')
|
|
|
|
# now defining term
|
|
defined[term] = False
|
|
|
|
if _is_keyword(term):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; keywords cannot be overridden.',
|
|
'jsonld.SyntaxError', {'context': local_ctx, 'term': term},
|
|
code='keyword redefinition')
|
|
|
|
if term == '':
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; a term cannot be an empty string.',
|
|
'jsonld.SyntaxError', {'context': local_ctx},
|
|
code='invalid term definition')
|
|
|
|
# remove old mapping
|
|
if term in active_ctx['mappings']:
|
|
del active_ctx['mappings'][term]
|
|
|
|
# get context term value
|
|
value = local_ctx[term]
|
|
|
|
# clear context entry
|
|
if (value is None or (_is_object(value) and '@id' in value and
|
|
value['@id'] is None)):
|
|
active_ctx['mappings'][term] = None
|
|
defined[term] = True
|
|
return
|
|
|
|
# convert short-hand value to object w/@id
|
|
if _is_string(value):
|
|
value = {'@id': value}
|
|
|
|
if not _is_object(value):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context property values must be '
|
|
'strings or objects.', 'jsonld.SyntaxError',
|
|
{'context': local_ctx}, code='invalid term definition')
|
|
|
|
# create new mapping
|
|
mapping = active_ctx['mappings'][term] = {'reverse': False}
|
|
|
|
if '@reverse' in value:
|
|
if '@id' in value:
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; an @reverse term definition must '
|
|
'not contain @id.', 'jsonld.SyntaxError',
|
|
{'context': local_ctx}, code='invalid reverse property')
|
|
reverse = value['@reverse']
|
|
if not _is_string(reverse):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context @reverse value must be '
|
|
'a string.', 'jsonld.SyntaxError', {'context': local_ctx},
|
|
code='invalid IRI mapping')
|
|
|
|
# expand and add @id mapping
|
|
id_ = self._expand_iri(
|
|
active_ctx, reverse, vocab=True, base=False,
|
|
local_ctx=local_ctx, defined=defined)
|
|
if not _is_absolute_iri(id_):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context @reverse value must be '
|
|
'an absolute IRI or a blank node identifier.',
|
|
'jsonld.SyntaxError', {'context': local_ctx},
|
|
code='invalid IRI mapping')
|
|
mapping['@id'] = id_
|
|
mapping['reverse'] = True
|
|
elif '@id' in value:
|
|
id_ = value['@id']
|
|
if not _is_string(id_):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context @id value must be a '
|
|
'string.', 'jsonld.SyntaxError',
|
|
{'context': local_ctx}, code='invalid IRI mapping')
|
|
if id_ != term:
|
|
# add @id to mapping
|
|
id_ = self._expand_iri(
|
|
active_ctx, id_, vocab=True, base=False,
|
|
local_ctx=local_ctx, defined=defined)
|
|
if not _is_absolute_iri(id_) and not _is_keyword(id_):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context @id value must be '
|
|
'an absolute IRI, a blank node identifier, or a '
|
|
'keyword.', 'jsonld.SyntaxError',
|
|
{'context': local_ctx}, code='invalid IRI mapping')
|
|
mapping['@id'] = id_
|
|
if '@id' not in mapping:
|
|
# see if the term has a prefix
|
|
colon = term.find(':')
|
|
if colon != -1:
|
|
prefix = term[0:colon]
|
|
if prefix in local_ctx:
|
|
# define parent prefix
|
|
self._create_term_definition(
|
|
active_ctx, local_ctx, prefix, defined)
|
|
|
|
# set @id based on prefix parent
|
|
if active_ctx['mappings'].get(prefix) is not None:
|
|
suffix = term[colon + 1:]
|
|
mapping['@id'] = (active_ctx['mappings'][prefix]['@id'] +
|
|
suffix)
|
|
# term is an absolute IRI
|
|
else:
|
|
mapping['@id'] = term
|
|
else:
|
|
# non-IRIs MUST define @ids if @vocab not available
|
|
if '@vocab' not in active_ctx:
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context terms must define '
|
|
'an @id.', 'jsonld.SyntaxError', {
|
|
'context': local_ctx,
|
|
'term': term
|
|
}, code='invalid IRI mapping')
|
|
# prepend vocab to term
|
|
mapping['@id'] = active_ctx['@vocab'] + term
|
|
|
|
# IRI mapping now defined
|
|
defined[term] = True
|
|
|
|
if '@type' in value:
|
|
type_ = value['@type']
|
|
if not _is_string(type_):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context @type value must be '
|
|
'a string.', 'jsonld.SyntaxError',
|
|
{'context': local_ctx}, code='invalid type mapping')
|
|
if type_ != '@id' and type_ != '@vocab':
|
|
# expand @type to full IRI
|
|
type_ = self._expand_iri(
|
|
active_ctx, type_, vocab=True,
|
|
local_ctx=local_ctx, defined=defined)
|
|
if not _is_absolute_iri(type_):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; an @context @type value must '
|
|
'be an absolute IRI.', 'jsonld.SyntaxError',
|
|
{'context': local_ctx}, code='invalid type mapping')
|
|
if type_.startswith('_:'):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; an @context @type values '
|
|
'must be an IRI, not a blank node identifier.',
|
|
'jsonld.SyntaxError', {'context': local_ctx},
|
|
code='invalid type mapping')
|
|
# add @type to mapping
|
|
mapping['@type'] = type_
|
|
|
|
if '@container' in value:
|
|
container = value['@container']
|
|
if container not in ['@list', '@set', '@index', '@language']:
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context @container value '
|
|
'must be one of the following: @list, @set, @index, or '
|
|
'@language.', 'jsonld.SyntaxError',
|
|
{'context': local_ctx}, code='invalid container mapping')
|
|
if (mapping['reverse'] and container != '@index' and
|
|
container != '@set' and container is not None):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context @container value for '
|
|
'an @reverse type definition must be @index or @set.',
|
|
'jsonld.SyntaxError', {'context': local_ctx},
|
|
code='invalid reverse property')
|
|
|
|
# add @container to mapping
|
|
mapping['@container'] = container
|
|
|
|
if '@language' in value and '@type' not in value:
|
|
language = value['@language']
|
|
if not (language is None or _is_string(language)):
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context @language value must be '
|
|
'a string or null.', 'jsonld.SyntaxError',
|
|
{'context': local_ctx}, code='invalid language mapping')
|
|
# add @language to mapping
|
|
if language is not None:
|
|
language = language.lower()
|
|
mapping['@language'] = language
|
|
|
|
# disallow aliasing @context and @preserve
|
|
id_ = mapping['@id']
|
|
if id_ == '@context' or id_ == '@preserve':
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; @context and @preserve '
|
|
'cannot be aliased.', 'jsonld.SyntaxError',
|
|
{'context': local_ctx}, code='invalid keyword alias')
|
|
|
|
def _expand_iri(
|
|
self, active_ctx, value, base=False, vocab=False,
|
|
local_ctx=None, defined=None):
|
|
"""
|
|
Expands a string value to a full IRI. The string may be a term, a
|
|
prefix, a relative IRI, or an absolute IRI. The associated absolute
|
|
IRI will be returned.
|
|
|
|
:param active_ctx: the current active context.
|
|
:param value: the string value to expand.
|
|
:param base: True to resolve IRIs against the base IRI, False not to.
|
|
:param vocab: True to concatenate after @vocab, False not to.
|
|
:param local_ctx: the local context being processed (only given if
|
|
called during context processing).
|
|
:param defined: a map for tracking cycles in context definitions (only
|
|
given if called during context processing).
|
|
|
|
:return: the expanded value.
|
|
"""
|
|
# already expanded
|
|
if value is None or _is_keyword(value):
|
|
return value
|
|
|
|
# define dependency not if defined
|
|
if (local_ctx and value in local_ctx and
|
|
defined.get(value) is not True):
|
|
self._create_term_definition(active_ctx, local_ctx, value, defined)
|
|
|
|
if vocab and value in active_ctx['mappings']:
|
|
mapping = active_ctx['mappings'].get(value)
|
|
# value is explicitly ignored with None mapping
|
|
if mapping is None:
|
|
return None
|
|
# value is a term
|
|
return mapping['@id']
|
|
|
|
# split value into prefix:suffix
|
|
if ':' in value:
|
|
prefix, suffix = value.split(':', 1)
|
|
|
|
# do not expand blank nodes (prefix of '_') or already-absolute
|
|
# IRIs (suffix of '//')
|
|
if prefix == '_' or suffix.startswith('//'):
|
|
return value
|
|
|
|
# prefix dependency not defined, define it
|
|
if local_ctx and prefix in local_ctx:
|
|
self._create_term_definition(
|
|
active_ctx, local_ctx, prefix, defined)
|
|
|
|
# use mapping if prefix is defined
|
|
mapping = active_ctx['mappings'].get(prefix)
|
|
if mapping:
|
|
return mapping['@id'] + suffix
|
|
|
|
# already absolute IRI
|
|
return value
|
|
|
|
# prepend vocab
|
|
if vocab and '@vocab' in active_ctx:
|
|
return active_ctx['@vocab'] + value
|
|
|
|
# resolve against base
|
|
rval = value
|
|
if base:
|
|
rval = prepend_base(active_ctx['@base'], rval)
|
|
|
|
return rval
|
|
|
|
def _find_context_urls(self, input_, urls, replace, base):
|
|
"""
|
|
Finds all @context URLs in the given JSON-LD input.
|
|
|
|
:param input_: the JSON-LD input.
|
|
:param urls: a map of URLs (url => False/@contexts).
|
|
:param replace: True to replace the URLs in the given input with
|
|
the @contexts from the urls map, False not to.
|
|
:param base: the base URL to resolve relative URLs against.
|
|
"""
|
|
if _is_array(input_):
|
|
for e in input_:
|
|
self._find_context_urls(e, urls, replace, base)
|
|
elif _is_object(input_):
|
|
for k, v in input_.items():
|
|
if k != '@context':
|
|
self._find_context_urls(v, urls, replace, base)
|
|
continue
|
|
|
|
# array @context
|
|
if _is_array(v):
|
|
length = len(v)
|
|
for i in range(length):
|
|
if _is_string(v[i]):
|
|
url = prepend_base(base, v[i])
|
|
# replace w/@context if requested
|
|
if replace:
|
|
ctx = urls[url]
|
|
if _is_array(ctx):
|
|
# add flattened context
|
|
v.pop(i)
|
|
for e in reversed(ctx):
|
|
v.insert(i, e)
|
|
i += len(ctx) - 1
|
|
length = len(v)
|
|
else:
|
|
v[i] = ctx
|
|
# @context URL found
|
|
elif url not in urls:
|
|
urls[url] = False
|
|
# string @context
|
|
elif _is_string(v):
|
|
v = prepend_base(base, v)
|
|
# replace w/@context if requested
|
|
if replace:
|
|
input_[k] = urls[v]
|
|
# @context URL found
|
|
elif v not in urls:
|
|
urls[v] = False
|
|
|
|
def _retrieve_context_urls(self, input_, cycles, load_document,
|
|
base: str = ''):
|
|
"""
|
|
Retrieves external @context URLs using the given document loader. Each
|
|
instance of @context in the input that refers to a URL will be
|
|
replaced with the JSON @context found at that URL.
|
|
|
|
:param input_: the JSON-LD input with possible contexts.
|
|
:param cycles: an object for tracking context cycles.
|
|
:param load_document(url): the document loader.
|
|
:param base: the base URL to resolve relative URLs against.
|
|
|
|
:return: the result.
|
|
"""
|
|
if len(cycles) > MAX_CONTEXT_URLS:
|
|
raise JsonLdError(
|
|
'Maximum number of @context URLs exceeded.',
|
|
'jsonld.ContextUrlError', {'max': MAX_CONTEXT_URLS},
|
|
code='loading remote context failed')
|
|
|
|
# for tracking URLs to retrieve
|
|
urls = {}
|
|
|
|
# find all URLs in the given input
|
|
self._find_context_urls(input_, urls, replace=False, base=base)
|
|
|
|
# queue all unretrieved URLs
|
|
queue = []
|
|
for url, ctx in urls.items():
|
|
if ctx is False:
|
|
queue.append(url)
|
|
|
|
# retrieve URLs in queue
|
|
for url in queue:
|
|
# check for context URL cycle
|
|
if url in cycles:
|
|
raise JsonLdError(
|
|
'Cyclical @context URLs detected.',
|
|
'jsonld.ContextUrlError', {'url': url},
|
|
code='recursive context inclusion')
|
|
cycles_ = copy.deepcopy(cycles)
|
|
cycles_[url] = True
|
|
|
|
# retrieve URL
|
|
try:
|
|
remote_doc = load_document(url)
|
|
ctx = remote_doc['document']
|
|
except Exception as cause:
|
|
raise JsonLdError(
|
|
'Dereferencing a URL did not result in a valid JSON-LD '
|
|
'context.',
|
|
'jsonld.ContextUrlError', {'url': url},
|
|
code='loading remote context failed', cause=cause)
|
|
|
|
# parse string context as JSON
|
|
if _is_string(ctx):
|
|
try:
|
|
ctx = json.loads(ctx)
|
|
except Exception as cause:
|
|
raise JsonLdError(
|
|
'Could not parse JSON from URL.',
|
|
'jsonld.ParseError', {'url': url},
|
|
code='loading remote context failed', cause=cause)
|
|
|
|
# ensure ctx is an object
|
|
if not _is_object(ctx):
|
|
raise JsonLdError(
|
|
'Dereferencing a URL did not result in a valid JSON-LD '
|
|
'object.',
|
|
'jsonld.InvalidUrl', {'url': url},
|
|
code='invalid remote context')
|
|
|
|
# use empty context if no @context key is present
|
|
if '@context' not in ctx:
|
|
ctx = {'@context': {}}
|
|
else:
|
|
ctx = {'@context': ctx['@context']}
|
|
|
|
# append context URL to context if given
|
|
if remote_doc['contextUrl'] is not None:
|
|
ctx['@context'] = JsonLdProcessor.arrayify(ctx['@context'])
|
|
ctx['@context'].append(remote_doc['contextUrl'])
|
|
|
|
# recurse
|
|
self._retrieve_context_urls(ctx, cycles_, load_document, url)
|
|
urls[url] = ctx['@context']
|
|
|
|
# replace all URLs in the input
|
|
self._find_context_urls(input_, urls, replace=True, base=base)
|
|
|
|
def _get_initial_context(self, options):
|
|
"""
|
|
Gets the initial context.
|
|
|
|
:param options: the options to use.
|
|
[base] the document base IRI.
|
|
|
|
:return: the initial context.
|
|
"""
|
|
return {
|
|
'@base': options['base'],
|
|
'mappings': {},
|
|
'inverse': None
|
|
}
|
|
|
|
def _get_inverse_context(self, active_ctx):
|
|
"""
|
|
Generates an inverse context for use in the compaction algorithm, if
|
|
not already generated for the given active context.
|
|
|
|
:param active_ctx: the active context to use.
|
|
|
|
:return: the inverse context.
|
|
"""
|
|
# inverse context already generated
|
|
if active_ctx['inverse']:
|
|
return active_ctx['inverse']
|
|
|
|
inverse = active_ctx['inverse'] = {}
|
|
|
|
# handle default language
|
|
default_language = active_ctx.get('@language', '@none')
|
|
|
|
# create term selections for each mapping in the context, ordered by
|
|
# shortest and then lexicographically least
|
|
for term, mapping in sorted(
|
|
active_ctx['mappings'].items(),
|
|
key=cmp_to_key(_compare_shortest_least)):
|
|
if mapping is None:
|
|
continue
|
|
|
|
# add term selection where it applies
|
|
container = mapping.get('@container', '@none')
|
|
|
|
# iterate over every IRI in the mapping
|
|
iris = JsonLdProcessor.arrayify(mapping['@id'])
|
|
for iri in iris:
|
|
container_map = inverse.setdefault(iri, {})
|
|
entry = container_map.setdefault(
|
|
container, {'@language': {}, '@type': {}})
|
|
|
|
# term is preferred for values using @reverse
|
|
if mapping['reverse']:
|
|
entry['@type'].setdefault('@reverse', term)
|
|
# term is preferred for values using specific type
|
|
elif '@type' in mapping:
|
|
entry['@type'].setdefault(mapping['@type'], term)
|
|
# term is preferred for values using specific language
|
|
elif '@language' in mapping:
|
|
language = mapping['@language']
|
|
if language is None:
|
|
language = '@null'
|
|
entry['@language'].setdefault(language, term)
|
|
# term is preferred for values w/default language or not type
|
|
# and no language
|
|
else:
|
|
# add an entry for the default language
|
|
entry['@language'].setdefault(default_language, term)
|
|
# add entries for no type and no language
|
|
entry['@type'].setdefault('@none', term)
|
|
entry['@language'].setdefault('@none', term)
|
|
|
|
return inverse
|
|
|
|
def _clone_active_context(self, active_ctx):
|
|
"""
|
|
Clones an active context, creating a child active context.
|
|
|
|
:param active_ctx: the active context to clone.
|
|
|
|
:return: a clone (child) of the active context.
|
|
"""
|
|
child = {
|
|
'@base': active_ctx['@base'],
|
|
'mappings': copy.deepcopy(active_ctx['mappings']),
|
|
'inverse': None
|
|
}
|
|
if '@language' in active_ctx:
|
|
child['@language'] = active_ctx['@language']
|
|
if '@vocab' in active_ctx:
|
|
child['@vocab'] = active_ctx['@vocab']
|
|
return child
|
|
|
|
|
|
# register the N-Quads RDF parser
|
|
register_rdf_parser('application/nquads', JsonLdProcessor.parse_nquads)
|
|
|
|
|
|
class JsonLdError(Exception):
|
|
"""
|
|
Base class for JSON-LD errors.
|
|
"""
|
|
|
|
def __init__(self, message, type_, details=None, code=None, cause=None):
|
|
Exception.__init__(self, message)
|
|
self.type = type_
|
|
self.details = details
|
|
self.code = code
|
|
self.cause = cause
|
|
self.causeTrace = traceback.extract_tb(*sys.exc_info()[2:])
|
|
|
|
def __str__(self):
|
|
if not hasattr(self, 'message'):
|
|
return 'Unknown exception'
|
|
rval = repr(self.message)
|
|
rval += '\nType: ' + self.type
|
|
if self.code:
|
|
rval += '\nCode: ' + self.code
|
|
if self.details:
|
|
rval += '\nDetails: ' + repr(self.details)
|
|
if self.cause:
|
|
rval += '\nCause: ' + str(self.cause)
|
|
rval += ''.join(traceback.format_list(self.causeTrace))
|
|
return rval
|
|
|
|
|
|
class UniqueNamer(object):
|
|
"""
|
|
A UniqueNamer issues unique names, keeping track of any previously issued
|
|
names.
|
|
"""
|
|
|
|
def __init__(self, prefix):
|
|
"""
|
|
Initializes a new UniqueNamer.
|
|
|
|
:param prefix: the prefix to use ('<prefix><counter>').
|
|
"""
|
|
self.prefix = prefix
|
|
self.counter = 0
|
|
self.existing = {}
|
|
self.order = []
|
|
|
|
"""
|
|
Gets the new name for the given old name, where if no old name is
|
|
given a new name will be generated.
|
|
|
|
:param [old_name]: the old name to get the new name for.
|
|
|
|
:return: the new name.
|
|
"""
|
|
def get_name(self, old_name=None):
|
|
# return existing old name
|
|
if old_name and old_name in self.existing:
|
|
return self.existing[old_name]
|
|
|
|
# get next name
|
|
name = self.prefix + str(self.counter)
|
|
self.counter += 1
|
|
|
|
# save mapping
|
|
if old_name is not None:
|
|
self.existing[old_name] = name
|
|
self.order.append(old_name)
|
|
|
|
return name
|
|
|
|
def is_named(self, old_name):
|
|
"""
|
|
Returns True if the given old name has already been assigned a new
|
|
name.
|
|
|
|
:param old_name: the old name to check.
|
|
|
|
:return: True if the old name has been assigned a new name, False if
|
|
not.
|
|
"""
|
|
return old_name in self.existing
|
|
|
|
|
|
def permutations(elements):
|
|
"""
|
|
Generates all of the possible permutations for the given list of elements.
|
|
|
|
: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:
|
|
raise StopIteration
|
|
|
|
# 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]]
|
|
|
|
|
|
def _compare_shortest_least(a, b):
|
|
"""
|
|
Compares two strings first based on length and then lexicographically.
|
|
|
|
:param a: the first string.
|
|
:param b: the second string.
|
|
|
|
:return: -1 if a < b, 1 if a > b, 0 if a == b.
|
|
"""
|
|
rval = cmp(len(a), len(b))
|
|
if rval == 0:
|
|
rval = cmp(a, b)
|
|
return rval
|
|
|
|
|
|
def _is_keyword(v):
|
|
"""
|
|
Returns whether or not the given value is a keyword.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is a keyword, False if not.
|
|
"""
|
|
if not _is_string(v):
|
|
return False
|
|
return v in KEYWORDS
|
|
|
|
|
|
def _is_object(v):
|
|
"""
|
|
Returns True if the given value is an Object.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is an Object, False if not.
|
|
"""
|
|
return isinstance(v, dict)
|
|
|
|
|
|
def _is_empty_object(v):
|
|
"""
|
|
Returns True if the given value is an empty Object.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is an empty Object, False if not.
|
|
"""
|
|
return _is_object(v) and len(v) == 0
|
|
|
|
|
|
def _is_array(v):
|
|
"""
|
|
Returns True if the given value is an Array.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is an Array, False if not.
|
|
"""
|
|
return isinstance(v, list)
|
|
|
|
|
|
def _is_string(v):
|
|
"""
|
|
Returns True if the given value is a String.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is a String, False if not.
|
|
"""
|
|
return isinstance(v, basestring)
|
|
|
|
|
|
def _validate_type_value(v):
|
|
"""
|
|
Raises an exception if the given value is not a valid @type value.
|
|
|
|
:param v: the value to check.
|
|
"""
|
|
# must be a string or empty object
|
|
if (_is_string(v) or _is_empty_object(v)):
|
|
return
|
|
|
|
# must be an array
|
|
is_valid = False
|
|
if _is_array(v):
|
|
# must contain only strings
|
|
is_valid = True
|
|
for e in v:
|
|
if not _is_string(e):
|
|
is_valid = False
|
|
break
|
|
|
|
if not is_valid:
|
|
raise JsonLdError(
|
|
'Invalid JSON-LD syntax; "@type" value must a string, an array of '
|
|
'strings, or an empty object.',
|
|
'jsonld.SyntaxError', {'value': v}, code='invalid type value')
|
|
|
|
|
|
def _is_bool(v):
|
|
"""
|
|
Returns True if the given value is a Boolean.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is a Boolean, False if not.
|
|
"""
|
|
return isinstance(v, bool)
|
|
|
|
|
|
def _is_integer(v):
|
|
"""
|
|
Returns True if the given value is an Integer.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is an Integer, False if not.
|
|
"""
|
|
return isinstance(v, Integral)
|
|
|
|
|
|
def _is_double(v):
|
|
"""
|
|
Returns True if the given value is a Double.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is a Double, False if not.
|
|
"""
|
|
return not isinstance(v, Integral) and isinstance(v, Real)
|
|
|
|
|
|
def _is_numeric(v):
|
|
"""
|
|
Returns True if the given value is numeric.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is numeric, False if not.
|
|
"""
|
|
try:
|
|
float(v)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _is_subject(v):
|
|
"""
|
|
Returns True if the given value is a subject with properties.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is a subject with properties, False if not.
|
|
"""
|
|
# Note: A value is a subject if all of these hold True:
|
|
# 1. It is an Object.
|
|
# 2. It is not a @value, @set, or @list.
|
|
# 3. It has more than 1 key OR any existing key is not @id.
|
|
rval = False
|
|
if (_is_object(v) and
|
|
'@value' not in v and '@set' not in v and '@list' not in v):
|
|
rval = len(v) > 1 or '@id' not in v
|
|
return rval
|
|
|
|
|
|
def _is_subject_reference(v):
|
|
"""
|
|
Returns True if the given value is a subject reference.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is a subject reference, False if not.
|
|
"""
|
|
# Note: A value is a subject reference if all of these hold True:
|
|
# 1. It is an Object.
|
|
# 2. It has a single key: @id.
|
|
return (_is_object(v) and len(v) == 1 and '@id' in v)
|
|
|
|
|
|
def _is_value(v):
|
|
"""
|
|
Returns True if the given value is a @value.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is a @value, False if not.
|
|
"""
|
|
# Note: A value is a @value if all of these hold True:
|
|
# 1. It is an Object.
|
|
# 2. It has the @value property.
|
|
return _is_object(v) and '@value' in v
|
|
|
|
|
|
def _is_list(v):
|
|
"""
|
|
Returns True if the given value is a @list.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is a @list, False if not.
|
|
"""
|
|
# Note: A value is a @list if all of these hold True:
|
|
# 1. It is an Object.
|
|
# 2. It has the @list property.
|
|
return _is_object(v) and '@list' in v
|
|
|
|
|
|
def _is_bnode(v):
|
|
"""
|
|
Returns True if the given value is a blank node.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is a blank node, False if not.
|
|
"""
|
|
# Note: A value is a blank node if all of these hold True:
|
|
# 1. It is an Object.
|
|
# 2. If it has an @id key its value begins with '_:'.
|
|
# 3. It has no keys OR is not a @value, @set, or @list.
|
|
rval = False
|
|
if _is_object(v):
|
|
if '@id' in v:
|
|
rval = v['@id'].startswith('_:')
|
|
else:
|
|
rval = (len(v) == 0 or not
|
|
('@value' in v or '@set' in v or '@list' in v))
|
|
return rval
|
|
|
|
|
|
def _is_absolute_iri(v):
|
|
"""
|
|
Returns True if the given value is an absolute IRI, False if not.
|
|
|
|
:param v: the value to check.
|
|
|
|
:return: True if the value is an absolute IRI, False if not.
|
|
"""
|
|
return ':' in v
|
|
|
|
|
|
class ActiveContextCache(object):
|
|
"""
|
|
An ActiveContextCache caches active contexts so they can be reused without
|
|
the overhead of recomputing them.
|
|
"""
|
|
|
|
def __init__(self, size: int = 100):
|
|
self.order = deque()
|
|
self.cache = {}
|
|
self.size = size
|
|
|
|
def get(self, active_ctx, local_ctx):
|
|
key1 = json.dumps(active_ctx)
|
|
key2 = json.dumps(local_ctx)
|
|
return self.cache.get(key1, {}).get(key2)
|
|
|
|
def set(self, active_ctx, local_ctx, result):
|
|
if len(self.order) == self.size:
|
|
entry = self.order.popleft()
|
|
del self.cache[entry['activeCtx']][entry['localCtx']]
|
|
key1 = json.dumps(active_ctx)
|
|
key2 = json.dumps(local_ctx)
|
|
self.order.append({'activeCtx': key1, 'localCtx': key2})
|
|
self.cache.setdefault(key1, {})[key2] = json.loads(json.dumps(result))
|
|
|
|
|
|
class VerifiedHTTPSConnection(HTTPSConnection):
|
|
"""
|
|
Used to verify SSL certificates when resolving URLs.
|
|
Taken from: http://thejosephturner.com/blog/2011/03/19/https-\
|
|
certificate-verification-in-python-with-urllib2/
|
|
"""
|
|
|
|
def connect(self):
|
|
global _trust_root_certificates
|
|
# overrides the version in httplib to do certificate verification
|
|
sock = socket.create_connection((self.host, self.port), self.timeout)
|
|
if self._tunnel_host:
|
|
self.sock = sock
|
|
self._tunnel()
|
|
# wrap the socket using verification with trusted_root_certs
|
|
self.sock = ssl.wrap_socket(sock,
|
|
self.key_file,
|
|
self.cert_file,
|
|
cert_reqs=ssl.CERT_REQUIRED,
|
|
ca_certs=_trust_root_certificates)
|
|
|
|
|
|
class VerifiedHTTPSHandler(HTTPSHandler):
|
|
"""
|
|
Wraps urllib2 HTTPS connections enabling SSL certificate verification.
|
|
"""
|
|
|
|
def __init__(self, connection_class=VerifiedHTTPSConnection):
|
|
self.specialized_conn_class = connection_class
|
|
HTTPSHandler.__init__(self)
|
|
|
|
def https_open(self, req):
|
|
return self.do_open(self.specialized_conn_class, req)
|
|
|
|
|
|
# the path to the system's default trusted root SSL certificates
|
|
_trust_root_certificates = None
|
|
_possible_trust_root_certificates = [
|
|
'/etc/ssl/certs/ca-certificates.crt',
|
|
'~/Library/OpenSSL/certs/ca-certificates.crt',
|
|
'/System/Library/OpenSSL/certs/ca-certificates.crt',
|
|
]
|
|
for path in _possible_trust_root_certificates:
|
|
path = os.path.expanduser(path)
|
|
if os.path.exists(path):
|
|
_trust_root_certificates = path
|
|
break
|
|
# FIXME: warn if not found? MacOS X uses keychain vs file.
|
|
|
|
|
|
# Shared in-memory caches.
|
|
_cache = {
|
|
'activeCtx': ActiveContextCache()
|
|
}
|