• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

CenterForOpenScience / SHARE / 14839804620

05 May 2025 03:11PM UTC coverage: 79.141% (-12.6%) from 91.745%
14839804620

push

github

web-flow
Merge pull request #859 from CenterForOpenScience/release/25.2.0

Release/25.2.0 share cleanupgrade (milestone 1: simplify)

147 of 204 new or added lines in 33 files covered. (72.06%)

984 existing lines in 69 files now uncovered.

6101 of 7709 relevant lines covered (79.14%)

1.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.16
/trove/render/jsonapi.py
1
import base64
2✔
2
from collections import defaultdict
2✔
3
import contextlib
2✔
4
import dataclasses
2✔
5
import datetime
2✔
6
import itertools
2✔
7
import json
2✔
8
import time
2✔
9
from typing import Iterable, Union
2✔
10

11
from primitive_metadata import primitive_rdf
2✔
12

13
from trove import exceptions as trove_exceptions
2✔
14
from trove.vocab.jsonapi import (
2✔
15
    JSONAPI_MEMBERNAME,
16
    JSONAPI_RELATIONSHIP,
17
    JSONAPI_ATTRIBUTE,
18
    JSONAPI_LINK_OBJECT,
19
)
20
from trove.vocab import mediatypes
2✔
21
from trove.vocab.namespaces import (
2✔
22
    OSFMAP,
23
    OWL,
24
    RDF,
25
    TROVE,
26
    XSD,
27
    NAMESPACES_SHORTHAND,
28
)
29
from trove.vocab.trove import (
2✔
30
    trove_indexcard_namespace,
31
)
32
from ._base import BaseRenderer
2✔
33

34

35
# a jsonapi resource may pull rdf data using an iri or blank node
36
# (using conventions from py for rdf as python primitives)
37
_IriOrBlanknode = Union[str, frozenset]
2✔
38

39

40
def _resource_ids_defaultdict():
2✔
41
    _prefix = str(time.time_ns())
2✔
42
    _ints = itertools.count()
2✔
43

44
    def _iter_ids():
2✔
45
        while True:
2✔
46
            _id = next(_ints)
2✔
47
            yield f'{_prefix}-{_id}'
2✔
48

49
    _ids = _iter_ids()
2✔
50
    return defaultdict(lambda: next(_ids))
2✔
51

52

53
@dataclasses.dataclass
2✔
54
class RdfJsonapiRenderer(BaseRenderer):
2✔
55
    '''render rdf data into jsonapi resources, guided by a given rdf vocabulary
56

57
    the given vocab describes how rdf predicates and classes in the data should
58
    map to jsonapi fields and resource objects in the rendered output, using
59
    `prefix jsonapi: <https://jsonapi.org/format/1.1/#>` and linked anchors in
60
    the jsonapi spec to represent jsonapi concepts:
61
      - jsonapi member name:
62
          `<iri> jsonapi:document-member-names "foo"@en`
63
      - jsonapi attribute:
64
          `<predicate_iri> rdf:type jsonapi:document-resource-object-attributes`
65
      - jsonapi relationship:
66
          `<predicate_iri> rdf:type jsonapi:document-resource-object-relationships`
67
      - to-one relationship or single-value attribute:
68
          `<predicate_iri> rdf:type owl:FunctionalProperty`
69

70
    note: does not support relationship links (or many other jsonapi features)
71
    '''
72
    MEDIATYPE = mediatypes.JSONAPI
2✔
73
    INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json']
2✔
74

75
    _identifier_object_cache: dict = dataclasses.field(default_factory=dict)
2✔
76
    _id_namespace_set: Iterable[primitive_rdf.IriNamespace] = (trove_indexcard_namespace(),)
2✔
77
    __to_include: set[primitive_rdf.RdfObject] | None = None
2✔
78
    __assigned_blanknode_resource_ids: defaultdict[frozenset, str] = dataclasses.field(
2✔
79
        default_factory=_resource_ids_defaultdict,
80
        repr=False,
81
    )
82

83
    def simple_render_document(self) -> str:
2✔
84
        return json.dumps(
2✔
85
            self.render_dict(self.response_focus.single_iri()),
86
            indent=2,  # TODO: pretty-print query param?
87
        )
88

89
    def render_dict(self, primary_iris: Union[str, Iterable[str]]) -> dict:
2✔
90
        _primary_data: dict | list | None = None
2✔
91
        _included_data = []
2✔
92
        with self._contained__to_include() as _to_include:
2✔
93
            if isinstance(primary_iris, str):
2✔
94
                _already_included = {primary_iris}
2✔
95
                _primary_data = self.render_resource_object(primary_iris)
2✔
96
            else:
97
                _already_included = set(primary_iris)
×
UNCOV
98
                _primary_data = [
×
99
                    self.render_resource_object(_iri)
100
                    for _iri in primary_iris
101
                ]
102
            while _to_include:
2✔
103
                _next = _to_include.pop()
2✔
104
                if _next not in _already_included:
2✔
105
                    _already_included.add(_next)
2✔
106
                    _included_data.append(self.render_resource_object(_next))
2✔
107
        _document = {'data': _primary_data}
2✔
108
        if _included_data:
2✔
109
            _document['included'] = _included_data
2✔
110
        return _document
2✔
111

112
    def render_resource_object(self, iri_or_blanknode: _IriOrBlanknode) -> dict:
2✔
113
        _resource_object = {**self.render_identifier_object(iri_or_blanknode)}
2✔
114
        _twopledict = (
2✔
115
            (self.response_data.tripledict.get(iri_or_blanknode) or {})
116
            if isinstance(iri_or_blanknode, str)
117
            else primitive_rdf.twopledict_from_twopleset(iri_or_blanknode)
118
        )
119
        for _pred, _obj_set in _twopledict.items():
2✔
120
            if _pred != RDF.type:
2✔
121
                self._render_field(_pred, _obj_set, into=_resource_object)
2✔
122
        if isinstance(iri_or_blanknode, str):
2✔
123
            _resource_object.setdefault('links', {})['self'] = iri_or_blanknode
2✔
124
        return _resource_object
2✔
125

126
    def render_identifier_object(self, iri_or_blanknode: _IriOrBlanknode):
2✔
127
        try:
2✔
128
            return self._identifier_object_cache[iri_or_blanknode]
2✔
129
        except KeyError:
2✔
130
            if isinstance(iri_or_blanknode, str):
2✔
131
                _type_iris = list(self.response_data.q(iri_or_blanknode, RDF.type))
2✔
132
                _id_obj = {
2✔
133
                    'id': self._resource_id_for_iri(iri_or_blanknode),
134
                    'type': self._single_typename(_type_iris),
135
                }
136
            elif isinstance(iri_or_blanknode, frozenset):
2✔
137
                _type_iris = [
2✔
138
                    _obj
139
                    for _pred, _obj in iri_or_blanknode
140
                    if _pred == RDF.type
141
                ]
142
                _id_obj = {
2✔
143
                    'id': self._resource_id_for_blanknode(iri_or_blanknode),
144
                    'type': self._single_typename(_type_iris),
145
                }
146
            else:
147
                raise trove_exceptions.ExpectedIriOrBlanknode(f'expected str or frozenset (got {iri_or_blanknode})')
×
148
            self._identifier_object_cache[iri_or_blanknode] = _id_obj
2✔
149
            return _id_obj
2✔
150

151
    def _single_typename(self, type_iris: list[str]):
2✔
152
        if not type_iris:
2✔
153
            raise trove_exceptions.MissingRdfType
2✔
154
        if len(type_iris) == 1:
2✔
155
            return self._membername_for_iri(type_iris[0])
2✔
156
        # choose one predictably, preferring osfmap and trove
157
        for _namespace in (OSFMAP, TROVE):
2✔
158
            _type_iris = sorted(_iri for _iri in type_iris if _iri in _namespace)
2✔
159
            if _type_iris:
2✔
160
                return self._membername_for_iri(_type_iris[0])
2✔
161
        return self._membername_for_iri(sorted(type_iris)[0])
×
162

163
    def _membername_for_iri(self, iri: str):
2✔
164
        try:
2✔
165
            _membername = next(self.thesaurus.q(iri, JSONAPI_MEMBERNAME))
2✔
166
        except StopIteration:
2✔
167
            pass
2✔
168
        else:
169
            if isinstance(_membername, primitive_rdf.Literal):
2✔
170
                return _membername.unicode_value
2✔
171
            raise trove_exceptions.ExpectedLiteralObject((iri, JSONAPI_MEMBERNAME, _membername))
×
172
        return self.iri_shorthand.compact_iri(iri)
2✔
173

174
    def _resource_id_for_blanknode(self, blanknode: frozenset, /):
2✔
175
        return self.__assigned_blanknode_resource_ids[blanknode]
2✔
176

177
    def _resource_id_for_iri(self, iri: str):
2✔
178
        for _iri_namespace in self._id_namespace_set:
2✔
179
            if iri in _iri_namespace:
2✔
180
                return primitive_rdf.iri_minus_namespace(iri, namespace=_iri_namespace)
×
181
        # as fallback, encode the iri into a valid jsonapi member name
182
        return base64.urlsafe_b64encode(iri.encode()).decode()
2✔
183

184
    def _render_field(self, predicate_iri, object_set, *, into: dict):
2✔
185
        _is_relationship = (predicate_iri, RDF.type, JSONAPI_RELATIONSHIP) in self.thesaurus
2✔
186
        _is_attribute = (predicate_iri, RDF.type, JSONAPI_ATTRIBUTE) in self.thesaurus
2✔
187
        _field_key = self._membername_for_iri(predicate_iri)
2✔
188
        _doc_key = 'meta'  # unless configured for jsonapi, default to unstructured 'meta'
2✔
189
        if ':' not in _field_key:
2✔
190
            if _is_relationship:
2✔
191
                _doc_key = 'relationships'
2✔
192
            elif _is_attribute:
2✔
193
                _doc_key = 'attributes'
2✔
194
        if _is_relationship:
2✔
195
            _fieldvalue = self._render_relationship_object(predicate_iri, object_set)
2✔
196
        else:
197
            _fieldvalue = self._one_or_many(predicate_iri, self._attribute_datalist(object_set))
2✔
198
        # update the given `into` resource object
199
        into.setdefault(_doc_key, {})[_field_key] = _fieldvalue
2✔
200

201
    def _one_or_many(self, predicate_iri: str, datalist: list):
2✔
202
        _only_one = (predicate_iri, RDF.type, OWL.FunctionalProperty) in self.thesaurus
2✔
203
        if _only_one:
2✔
204
            if len(datalist) > 1:
2✔
205
                raise trove_exceptions.OwlObjection(f'multiple objects for to-one relation <{predicate_iri}>: {datalist}')
×
206
            return (datalist[0] if datalist else None)
2✔
207
        return datalist
2✔
208

209
    def _attribute_datalist(self, object_set):
2✔
210
        return [
2✔
211
            self._render_attribute_datum(_obj)
212
            for _obj in object_set
213
        ]
214

215
    def _render_relationship_object(self, predicate_iri, object_set):
2✔
216
        _data = []
2✔
217
        _links = {}
2✔
218
        for _obj in object_set:
2✔
219
            if isinstance(_obj, frozenset):
2✔
220
                if (RDF.type, RDF.Seq) in _obj:
2✔
221
                    for _seq_obj in primitive_rdf.sequence_objects_in_order(_obj):
2✔
222
                        _data.append(self.render_identifier_object(_seq_obj))
2✔
223
                        self._pls_include(_seq_obj)
2✔
224
                elif (RDF.type, JSONAPI_LINK_OBJECT) in _obj:
×
225
                    _key, _link_obj = self._render_link_object(_obj)
×
226
                    _links[_key] = _link_obj
×
227
                else:
228
                    _data.append(self.render_identifier_object(_obj))
×
229
                    self._pls_include(_obj)
×
230
            else:
231
                assert isinstance(_obj, str)
2✔
232
                _data.append(self.render_identifier_object(_obj))
2✔
233
                self._pls_include(_obj)
2✔
234
        _relationship_obj = {
2✔
235
            'data': self._one_or_many(predicate_iri, _data),
236
        }
237
        if _links:
2✔
238
            _relationship_obj['links'] = _links
×
239
        return _relationship_obj
2✔
240

241
    def _render_link_object(self, link_obj: frozenset):
2✔
UNCOV
242
        _membername = next(
×
243
            _obj.unicode_value
244
            for _pred, _obj in link_obj
245
            if _pred == JSONAPI_MEMBERNAME
246
        )
UNCOV
247
        _rendered_link = {
×
248
            'href': next(
249
                _obj
250
                for _pred, _obj in link_obj
251
                if _pred == RDF.value
252
            ),
253
            # TODO:
254
            # 'rel':
255
            # 'describedby':
256
            # 'title':
257
            # 'type':
258
            # 'hreflang':
259
            # 'meta':
260
        }
UNCOV
261
        return _membername, _rendered_link
×
262

263
    def _make_object_gen(self, object_set):
2✔
UNCOV
264
        for _obj in object_set:
×
UNCOV
265
            if isinstance(_obj, frozenset) and ((RDF.type, RDF.Seq) in _obj):
×
UNCOV
266
                yield from primitive_rdf.sequence_objects_in_order(_obj)
×
267
            else:
UNCOV
268
                yield _obj
×
269

270
    @contextlib.contextmanager
2✔
271
    def _contained__to_include(self):
2✔
272
        assert self.__to_include is None
2✔
273
        self.__to_include = set()
2✔
274
        try:
2✔
275
            yield self.__to_include
2✔
276
        finally:
277
            self.__to_include = None
2✔
278

279
    def _pls_include(self, item):
2✔
280
        if self.__to_include is not None:
2✔
281
            self.__to_include.add(item)
2✔
282

283
    def _render_attribute_datum(self, rdfobject: primitive_rdf.RdfObject) -> dict | list | str | float | int:
2✔
284
        if isinstance(rdfobject, frozenset):
2✔
UNCOV
285
            if (RDF.type, RDF.Seq) in rdfobject:
×
UNCOV
286
                return [
×
287
                    self._render_attribute_datum(_seq_obj)
288
                    for _seq_obj in primitive_rdf.sequence_objects_in_order(rdfobject)
289
                ]
UNCOV
290
            _json_blanknode = {}
×
UNCOV
291
            for _pred, _obj_set in primitive_rdf.twopledict_from_twopleset(rdfobject).items():
×
UNCOV
292
                _key = self._membername_for_iri(_pred)
×
UNCOV
293
                _json_blanknode[_key] = self._one_or_many(_pred, self._attribute_datalist(_obj_set))
×
UNCOV
294
            return _json_blanknode
×
295
        if isinstance(rdfobject, primitive_rdf.Literal):
2✔
296
            if RDF.JSON in rdfobject.datatype_iris:
2✔
297
                return json.loads(rdfobject.unicode_value)
2✔
298
            if XSD.integer in rdfobject.datatype_iris:
2✔
299
                return int(rdfobject.unicode_value)
2✔
300
            return rdfobject.unicode_value  # TODO: decide how to represent language
2✔
301
        elif isinstance(rdfobject, str):
2✔
302
            try:  # maybe it's a jsonapi resource
2✔
303
                return self.render_identifier_object(rdfobject)
2✔
304
            except Exception:
2✔
305
                return NAMESPACES_SHORTHAND.compact_iri(rdfobject)
2✔
UNCOV
306
        elif isinstance(rdfobject, (float, int)):
×
UNCOV
307
            return rdfobject
×
UNCOV
308
        elif isinstance(rdfobject, datetime.date):
×
309
            # just "YYYY-MM-DD"
UNCOV
310
            return datetime.date.isoformat(rdfobject)
×
UNCOV
311
        raise trove_exceptions.UnsupportedRdfObject(rdfobject)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc