Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 63 additions & 7 deletions tests/test_literals.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def test_untyped() -> None:
"""Test creating a untyped literal."""
import pytest

from tripper.literal import RDF, XSD, Literal
from tripper.literal import XSD, Literal

literal = Literal("Hello world!")
assert literal == "Hello world!"
Expand All @@ -18,9 +18,6 @@ def test_untyped() -> None:
assert literal.to_python() == "Hello world!"
assert literal.value == "Hello world!"
assert literal.n3() == '"Hello world!"'
assert literal == Literal("Hello world!", datatype=XSD.string)
assert literal == Literal("Hello world!", datatype=XSD.token)
assert literal == Literal("Hello world!", datatype=RDF.JSON)
assert literal == Literal("Hello world!", lang="en")

# Check two things here:
Expand Down Expand Up @@ -129,6 +126,43 @@ def test_hexbinary() -> None:
assert literal.n3() == f'"1f"^^<{XSD.hexBinary}>'


def test_json() -> None:
"""Test creating JSON literal."""
import json

import pytest

from tripper import RDF, Literal

literal = Literal(None)
assert literal.value is None
assert literal.lang is None
assert literal.datatype == RDF.JSON

literal = Literal({"a": 1, "b": [2.2, None, True]})
assert literal.value == {"a": 1, "b": [2.2, None, True]}
assert literal.lang is None
assert literal.datatype == RDF.JSON

literal = Literal(["a", 1, True, {"a": 2.2, "b": None}])
assert literal.value == ["a", 1, True, {"a": 2.2, "b": None}]
assert literal.lang is None
assert literal.datatype == RDF.JSON

literal = Literal('{"a": 1}', datatype=RDF.JSON)
assert literal.value == {"a": 1}
assert literal.lang is None
assert literal.datatype == RDF.JSON

literal = Literal('"a"', datatype=RDF.JSON)
assert literal.value == "a"
assert literal.lang is None
assert literal.datatype == RDF.JSON

with pytest.raises(json.JSONDecodeError):
literal = Literal("a", datatype=RDF.JSON)


def test_float_through_datatype() -> None:
"""Test creating a float literal from an int through datatype."""
from tripper import XSD, Literal
Expand Down Expand Up @@ -219,11 +253,21 @@ def test_parse_literal() -> None:
assert literal.lang is None
assert literal.datatype == XSD.string

literal = parse_literal(3)
assert literal.value == 3
assert literal.lang is None
assert literal.datatype == XSD.integer

literal = parse_literal("3")
assert literal.value == 3
assert literal.lang is None
assert literal.datatype == XSD.integer

literal = parse_literal(3.14)
assert literal.value == 3.14
assert literal.lang is None
assert literal.datatype == XSD.double

literal = parse_literal("3.14")
assert literal.value == 3.14
assert literal.lang is None
Expand All @@ -239,13 +283,13 @@ def test_parse_literal() -> None:
assert literal.lang is None
assert literal.datatype == RDF.HTML

literal = parse_literal(f'"text"^^<{RDF.HTML}>')
literal = parse_literal(f'"""text"""^^<{RDF.HTML}>')
assert literal.value == "text"
assert literal.lang is None
assert literal.datatype == RDF.HTML

literal = parse_literal(f'"""["a", 1, 2]"""^^<{RDF.JSON}>')
assert literal.value == '["a", 1, 2]'
assert literal.value == ["a", 1, 2]
assert literal.lang is None
assert literal.datatype == RDF.JSON

Expand All @@ -255,6 +299,18 @@ def test_parse_literal() -> None:
assert literal.lang is None
assert literal.datatype == "http://example.com/vocab#mytype"

literal = parse_literal({"a": 1, "b": [2.2, None, True]})
assert literal.value == {"a": 1, "b": [2.2, None, True]}
assert literal.lang is None
assert literal.datatype == RDF.JSON

literal = parse_literal(
f'"""{{"a": 1, "b": [2.2, null, true]}}"""^^<{RDF.JSON}>'
)
assert literal.value == {"a": 1, "b": [2.2, None, True]}
assert literal.lang is None
assert literal.datatype == RDF.JSON


def test_rdflib_literal():
"""Test parsing rdflib literals."""
Expand All @@ -278,7 +334,7 @@ def test_rdflib_literal():

rdflib_literal = rdflib.Literal('["a", 1, 2]', datatype=rdflib.RDF.JSON)
literal = parse_literal(rdflib_literal)
assert literal.value == '["a", 1, 2]'
assert literal.value == ["a", 1, 2]
assert literal.lang is None
assert literal.datatype == RDF.JSON

Expand Down
16 changes: 7 additions & 9 deletions tripper/backends/rdflib.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,21 @@
from tripper.triplestore import Triple


def asuri(value: "Union[None, Literal, str]"):
def tordflib(value: "Union[None, Literal, str]"):
"""Help function converting a spo-value to proper rdflib type."""
if value is None:
return None
if isinstance(value, Literal):
return rdflibLiteral(
value.value, lang=value.lang, datatype=value.datatype
)
return rdflibLiteral(value, lang=value.lang, datatype=value.datatype)
if value.startswith("_:"):
return BNode(value[2:])
return URIRef(value)


def astriple(triple: "Triple"):
def totriple(triple: "Triple"):
"""Help function converting a triple to rdflib triple."""
s, p, o = triple
return asuri(s), asuri(p), asuri(o)
return tordflib(s), tordflib(p), tordflib(o)


class RdflibStrategy:
Expand Down Expand Up @@ -95,17 +93,17 @@ def __init__(
def triples(self, triple: "Triple") -> "Generator[Triple, None, None]":
"""Returns a generator over matching triples."""
return _convert_triples_to_tripper(
self.graph.triples(astriple(triple))
self.graph.triples(totriple(triple))
)

def add_triples(self, triples: "Sequence[Triple]"):
"""Add a sequence of triples."""
for triple in triples:
self.graph.add(astriple(triple))
self.graph.add(totriple(triple))

def remove(self, triple: "Triple"):
"""Remove all matching triples from the backend."""
self.graph.remove(astriple(triple))
self.graph.remove(totriple(triple))

# Optional methods
def close(self):
Expand Down
114 changes: 95 additions & 19 deletions tripper/literal.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,82 @@
"""Literal rdf values."""
"""Literal RDF values.

Literals may be used as objects in RDF triples to provide a value to a
resource.

See also https://www.w3.org/TR/rdf11-concepts/#section-Graph-Literal

"""

import json
import warnings
from datetime import datetime
from typing import TYPE_CHECKING

from tripper.namespace import RDF, RDFS, XSD

if TYPE_CHECKING: # pragma: no cover
from typing import Any, Optional, Union
from typing import Optional, Union


class Literal(str):
"""A literal RDF value.

Arguments:
value (Union[datetime, bytes, bytearray, bool, int, float, str]):
The literal value. See the `datatypes` class attribute for valid
supported data types. A localised string is provided as a string
with `lang` set to a language code.
lang (Optional[str]): A standard language code, like "en", "no", etc.
Implies that the `value` is a localised string.
datatype (Any): Explicit specification of the type of `value`. Should
not be combined with `lang`.
"""
value (Union[datetime, bytes, bytearray, bool, int, float,
str, None, dict, list]): The literal value. Can be
given as a string or a Python object.
lang (Optional[str]): A standard language code, like "en",
"no", etc. Implies that the `value` is a language-tagged
string.
datatype (Optional[str, type]): The datatype of this literal.
Can be given either as a string with the datatype IRI (ex:
`"http://www.w3.org/2001/XMLSchema#integer"`) or as a
Python type (ex: `int`). If not given, the datatype is
inferred from `value`. Should not be combined with
`lang`.

lang: "Union[str, None]"
datatype: "Any"
Examples:

>>> from tripper import XSD, Literal

# Inferring the data type
>>> l1 = Literal(42)
>>> l1
Literal('42', datatype='http://www.w3.org/2001/XMLSchema#integer')

>>> l1.value
42

# String values with no datatype are assumed to be strings
>>> l2 = Literal("42")
>>> l2.value
'42'

# Explicit providing the datatype
>>> l3 = Literal("42", datatype=XSD.integer)
>>> l3
Literal('42', datatype='http://www.w3.org/2001/XMLSchema#integer')

>>> l3.value
42

# Localised or language-tagged string literal
>>> Literal("Hello world", lang="en")
Literal('Hello world', lang='en')

# Dicts, lists and None are assumed to be of type rdf:JSON
>>> l4 = Literal({"name": "Jon Doe"})
>>> l4.datatype
'http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON'

>>> l4.value
{'name': 'Jon Doe'}

# Literal of custom datatype (`value` must be a string)
>>> Literal("my value...", datatype="http://example.com/onto#MyType")
Literal('my value...', datatype='http://example.com/onto#MyType')

"""

# Note that the order of datatypes matters - it is used by
# utils.parse_literal() when inferring the datatype of a literal.
Expand Down Expand Up @@ -71,15 +122,24 @@ class Literal(str):
XSD.normalizedString,
XSD.token,
),
list: (RDF.JSON,),
dict: (RDF.JSON,),
None.__class__: (RDF.JSON,),
}

lang: "Union[str, None]"
datatype: "Union[str, None]"

def __new__(
cls,
value: "Union[datetime, bytes, bytearray, bool, int, float, str]",
value: (
"Union[datetime, bytes, bytearray, bool, int, float, str, None, "
"dict, list]"
),
lang: "Optional[str]" = None,
datatype: "Optional[Any]" = None,
datatype: "Optional[Union[str, type]]" = None,
):
# pylint: disable=too-many-branches
# pylint: disable=too-many-branches,too-many-statements
string = super().__new__(cls, value)
string.lang = None
string.datatype = None
Expand All @@ -94,8 +154,18 @@ def __new__(

# Get datatype
elif datatype in cls.datatypes:
string.datatype = cls.datatypes[datatype][0]
string.datatype = cls.datatypes[datatype][0] # type: ignore
elif datatype == RDF.JSON:
if isinstance(value, str):
# Raises an exception if `value` is not a valid JSON string
json.loads(value)
else:
value = json.dumps(value)
string = super().__new__(cls, value)
string.lang = None
string.datatype = RDF.JSON
elif datatype:
assert isinstance(datatype, str) # nosec
# Create canonical representation of value for
# given datatype
val = None
Expand Down Expand Up @@ -137,6 +207,10 @@ def __new__(
string.datatype = XSD.hexBinary
elif isinstance(value, datetime):
string.datatype = XSD.dateTime
elif value is None or isinstance(value, (dict, list)):
string = super().__new__(cls, json.dumps(value))
string.lang = None
string.datatype = RDF.JSON

# Some consistency checking
if (
Expand Down Expand Up @@ -214,8 +288,6 @@ def __repr__(self) -> str:
def to_python(self):
"""Returns an appropriate python datatype derived from this RDF
literal."""
value = str(self)

if self.datatype == XSD.boolean:
value = False if str(self) == "False" else bool(self)
elif self.datatype in self.datatypes[int]:
Expand All @@ -224,6 +296,10 @@ def to_python(self):
value = float(self)
elif self.datatype == XSD.dateTime:
value = datetime.fromisoformat(self)
elif self.datatype == RDF.JSON:
value = json.loads(str(self))
else:
value = str(self)

return value

Expand Down