Projet

Général

Profil

0001-utils-add-JSON-flattening-helpers-37482.patch

Benjamin Dauvergne, 18 novembre 2019 15:54

Télécharger (20,6 ko)

Voir les différences:

Subject: [PATCH] utils: add JSON flattening helpers (#37482)

* flatten/unflatten JSON document
* flatten JSON schema (to help users in producing flattened JSON
  documents, not to validate, validation must be done through
  unflattening then validating using the original JSON schema)
 passerelle/apps/bdp/views.py                  |   2 +-
 passerelle/apps/clicrdv/views.py              |   2 +-
 passerelle/apps/gdc/views.py                  |   2 +-
 passerelle/apps/pastell/views.py              |   2 +-
 passerelle/contrib/agoraplus/views.py         |   2 +-
 passerelle/contrib/fake_family/views.py       |   2 +-
 passerelle/contrib/maarch/views.py            |   2 +-
 .../contrib/meyzieu_newsletters/views.py      |   2 +-
 passerelle/contrib/seisin_by_email/views.py   |   2 +-
 passerelle/contrib/solis_apa/views.py         |   2 +-
 passerelle/utils/__init__.py                  |   5 +-
 passerelle/utils/api.py                       |   2 +
 passerelle/utils/json.py                      | 156 +++++++++++++++
 passerelle/utils/jsonresponse.py              |   2 +
 passerelle/utils/sftp.py                      |   2 +
 passerelle/utils/wcs.py                       |   1 +
 tests/test_utils_json.py                      | 177 ++++++++++++++++++
 17 files changed, 354 insertions(+), 11 deletions(-)
 create mode 100644 passerelle/utils/json.py
 create mode 100644 tests/test_utils_json.py
passerelle/apps/bdp/views.py
4 4
from django.views.generic.base import View
5 5
from django.views.generic.detail import SingleObjectMixin, DetailView
6 6

  
7
from passerelle import utils
7
import passerelle.utils as utils
8 8

  
9 9
from .models import Bdp
10 10

  
passerelle/apps/clicrdv/views.py
3 3
from django.views.generic.base import View
4 4
from django.views.generic.detail import SingleObjectMixin, DetailView
5 5

  
6
from passerelle import utils
6
import passerelle.utils as utils
7 7

  
8 8
from .models import ClicRdv
9 9

  
passerelle/apps/gdc/views.py
5 5
from django.views.generic.base import View
6 6
from django.views.generic.detail import SingleObjectMixin, DetailView
7 7

  
8
from passerelle import utils
8
import passerelle.utils as utils
9 9

  
10 10
from .models import Gdc, phpserialize, phpserialize_loads, SOAPpy
11 11

  
passerelle/apps/pastell/views.py
4 4
from django.views.generic.detail import SingleObjectMixin, DetailView
5 5
from django.views.generic.edit import UpdateView
6 6

  
7
from passerelle import utils
7
import passerelle.utils as utils
8 8

  
9 9
from .models import Pastell
10 10
from .forms import PastellTypeForm, PastellFieldsForm
passerelle/contrib/agoraplus/views.py
26 26
from django.utils.translation import ugettext_lazy as _
27 27
from django.utils.http import urlencode
28 28

  
29
from passerelle import utils
29
import passerelle.utils as utils
30 30

  
31 31
from .models import AgoraPlus, AgoraPlusLink, AgoraAPIError
32 32
from .wcs import Formdata
passerelle/contrib/fake_family/views.py
16 16

  
17 17
from django.views.generic import DetailView as GenericDetailView
18 18

  
19
from passerelle import utils
19
import passerelle.utils as utils
20 20

  
21 21
from .models import FakeFamily
22 22

  
passerelle/contrib/maarch/views.py
24 24
from django.utils.decorators import method_decorator
25 25
from django.views.decorators.csrf import csrf_exempt
26 26

  
27
from passerelle import utils
27
import passerelle.utils as utils
28 28
from passerelle.soap import sudsobject_to_dict, client_to_jsondict
29 29

  
30 30
from .soap import get_client
passerelle/contrib/meyzieu_newsletters/views.py
19 19
from django.views.generic import DetailView as GenericDetailView, View
20 20
from django.views.decorators.csrf import csrf_exempt
21 21

  
22
from passerelle import utils
22
import passerelle.utils as utils
23 23

  
24 24
from .models import MeyzieuNewsletters
25 25

  
passerelle/contrib/seisin_by_email/views.py
20 20
from django.utils.decorators import method_decorator
21 21
from django.views.decorators.csrf import csrf_exempt
22 22

  
23
from passerelle import utils
23
import passerelle.utils as utils
24 24
from passerelle.soap import sudsobject_to_dict, client_to_jsondict
25 25

  
26 26
from .soap import get_client
passerelle/contrib/solis_apa/views.py
20 20
from django.views.decorators.csrf import csrf_exempt
21 21
from django.utils.translation import ugettext_lazy as _
22 22

  
23
from passerelle import utils
23
import passerelle.utils as utils
24 24

  
25 25
from .models import SolisAPA
26 26

  
passerelle/utils/__init__.py
13 13
# You should have received a copy of the GNU Affero General Public License
14 14
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 15

  
16
from __future__ import absolute_import
17

  
16 18
from cStringIO import StringIO
17 19
from functools import wraps
18 20
import hashlib
19
import json
20 21
import re
21 22
from itertools import islice, chain
22 23
import warnings
......
39 40

  
40 41

  
41 42
def response_for_json(request, data):
43
    import json
44

  
42 45
    response = HttpResponse(content_type='application/json')
43 46
    json_str = json.dumps(data)
44 47
    for variable in ('jsonpCallback', 'callback'):
passerelle/utils/api.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
from __future__ import absolute_import
18

  
17 19
import inspect
18 20

  
19 21
from django.core.urlresolvers import reverse
passerelle/utils/json.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
# passerelle - uniform access to multiple data sources and services
17
# Copyright (C) 2018 Entr'ouvert
18
#
19
# This program is free software: you can redistribute it and/or modify it
20
# under the terms of the GNU Affero General Public License as published
21
# by the Free Software Foundation, either version 3 of the License, or
22
# (at your option) any later version.
23
#
24
# This program is distributed in the hope that it will be useful,
25
# but WITHOUT ANY WARRANTY; without even the implied warranty of
26
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
27
# GNU Affero General Public License for more details.
28
#
29
# You should have received a copy of the GNU Affero General Public License
30
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
31

  
32
from __future__ import unicode_literals
33

  
34
from django.utils import six
35

  
36

  
37
FLATTEN_SEPARATOR = '/'
38

  
39

  
40
def unflatten(d, separator=FLATTEN_SEPARATOR):
41
    '''Transform:
42

  
43
          {"a/b/0/x": "1234"}
44

  
45
       into:
46

  
47
          {"a": {"b": [{"x": "1234"}]}}
48
    '''
49
    if not isinstance(d, dict) or not d:  # unflattening an empty dict has no sense
50
        return d
51

  
52
    # ok d is a dict
53

  
54
    def map_digits(l):
55
        return [int(x) if x.isdigit() else x for x in l]
56
    keys = [(map_digits(key.split(separator)), key) for key in d]
57
    keys.sort()
58

  
59
    def set_path(path, orig_key, d, value, i=0):
60
        assert path
61

  
62
        key, tail = path[i], path[i + 1:]
63

  
64
        if not tail:  # end of path, set thevalue
65
            if isinstance(key, int):
66
                assert isinstance(d, list)
67
                if len(d) != key:
68
                    raise ValueError('incomplete array before %s' % orig_key)
69
                d.append(value)
70
            else:
71
                assert isinstance(d, dict)
72
                d[key] = value
73
        else:
74
            new = [] if isinstance(tail[0], int) else {}
75

  
76
            if isinstance(key, int):
77
                assert isinstance(d, list)
78
                if len(d) < key:
79
                    raise ValueError('incomplete array before %s in %s' % (
80
                        separator.join(map(str, path[:i + 1])),
81
                        orig_key))
82
                elif len(d) == key:
83
                    d.append(new)
84
                else:
85
                    new = d[key]
86
            else:
87
                new = d.setdefault(key, new)
88
            set_path(path, orig_key, new, value, i + 1)
89

  
90
    # Is the first level an array or a dict ?
91
    if isinstance(keys[0][0][0], int):
92
        new = []
93
    else:
94
        new = {}
95
    for path, key in keys:
96
        value = d[key]
97
        set_path(path, key, new, value)
98
    return new
99

  
100

  
101
def flatten(data, separator=FLATTEN_SEPARATOR):
102
    assert isinstance(data, (list, dict))
103

  
104
    def helper(data):
105
        if isinstance(data, list):
106
            for i, value in enumerate(data):
107
                for path, value in helper(value):
108
                    yield [str(i)] + path, value
109
        elif isinstance(data, dict):
110
            for key, value in six.iteritems(data):
111
                for path, value in helper(value):
112
                    yield [str(key)] + path, value
113
        else:
114
            yield [], data
115
    return {separator.join(path): value for path, value in helper(data)}
116

  
117

  
118
def flatten_json_schema(schema, separator=FLATTEN_SEPARATOR):
119
    assert isinstance(schema, dict)
120

  
121
    def helper(prefix, schema):
122
        if 'oneOf' in schema:
123
            schemas_by_keys = {}
124
            for subschema in schema['oneOf']:
125
                for key, schema in helper(prefix, subschema):
126
                    schemas_by_keys.setdefault(key, []).append(schema)
127
            for key in schemas_by_keys:
128
                schemas = schemas_by_keys[key]
129
                if len(schemas) > 1:
130
                    yield key, {'oneOf': schemas}
131
                else:
132
                    yield key, schemas[0]
133
        elif schema['type'] == 'array':
134
            prefix = prefix + separator if prefix else prefix
135
            subschema = schema['items']
136
            max_items = schema.get('maxItems', 3)
137
            for i in range(max_items):
138
                for key, schema in helper(str(i), subschema):
139
                    yield '%s%s' % (prefix, key), schema
140
        elif schema['type'] == 'object':
141
            prefix = prefix + separator if prefix else prefix
142
            properties = schema['properties']
143
            for key in properties:
144
                for subkey, schema in helper(key, properties[key]):
145
                    yield '%s%s' % (prefix, subkey), schema
146
        else:
147
            yield prefix, schema
148

  
149
    return {
150
        'type': 'object',
151
        'description': 'flattened schema *never* use for validation',
152
        'properties': {
153
            key: schema for key, schema in helper('', schema)
154
        },
155
        'additionalProperties': False,
156
    }
passerelle/utils/jsonresponse.py
2 2
# django-jsonresponse (https://github.com/jjay/django-jsonresponse) distributed
3 3
# under BSD license
4 4

  
5
from __future__ import absolute_import
6

  
5 7
import datetime
6 8
import json
7 9
import functools
passerelle/utils/sftp.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
from __future__ import absolute_import
18

  
17 19
import os
18 20
import re
19 21
import io
passerelle/utils/wcs.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
from __future__ import absolute_import
17 18

  
18 19
import collections
19 20
import base64
tests/test_utils_json.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2018 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
# passerelle - uniform access to multiple data sources and services
17
# Copyright (C) 2018 Entr'ouvert
18
#
19
# This program is free software: you can redistribute it and/or modify it
20
# under the terms of the GNU Affero General Public License as published
21
# by the Free Software Foundation, either version 3 of the License, or
22
# (at your option) any later version.
23
#
24
# This program is distributed in the hope that it will be useful,
25
# but WITHOUT ANY WARRANTY; without even the implied warranty of
26
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
27
# GNU Affero General Public License for more details.
28
#
29
# You should have received a copy of the GNU Affero General Public License
30
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
31

  
32
import pytest
33

  
34
import jsonschema
35

  
36
from passerelle.utils.json import flatten, unflatten, flatten_json_schema, FLATTEN_SEPARATOR as SEP
37

  
38

  
39
def test_unflatten_base():
40
    assert unflatten('') == ''
41
    assert unflatten('a') == 'a'
42
    assert unflatten([]) == []
43
    assert unflatten([1]) == [1]
44
    assert unflatten({}) == {}
45
    assert unflatten(0) == 0
46
    assert unflatten(1) == 1
47
    assert unflatten(False) is False
48
    assert unflatten(True) is True
49

  
50

  
51
def test_unflatten_dict():
52
    assert unflatten({
53
        'a' + SEP + 'b' + SEP + '0': 1,
54
        'a' + SEP + 'c' + SEP + '1': 'a',
55
        'a' + SEP + 'b' + SEP + '1': True,
56
        'a' + SEP + 'c' + SEP + '0': [1],
57
    }) == {
58
        'a': {
59
            'b': [1, True],
60
            'c': [[1], 'a'],
61
        }
62
    }
63

  
64

  
65
def test_unflatten_array():
66
    assert unflatten({
67
        '0' + SEP + 'b' + SEP + '0': 1,
68
        '1' + SEP + 'c' + SEP + '1': 'a',
69
        '0' + SEP + 'b' + SEP + '1': True,
70
        '1' + SEP + 'c' + SEP + '0': [1],
71
    }) == [{'b': [1, True]},
72
           {'c': [[1], 'a']}]
73

  
74

  
75
def test_unflatten_missing_final_index():
76
    with pytest.raises(ValueError) as exc_info:
77
        unflatten({
78
            '1': 1
79
        })
80
    assert 'incomplete' in exc_info.value.args[0]
81

  
82

  
83
def test_unflatten_missing_intermediate_index():
84
    with pytest.raises(ValueError) as exc_info:
85
        unflatten({
86
            'a' + SEP + '1' + SEP + 'b': 1
87
        })
88
    assert 'incomplete' in exc_info.value.args[0]
89

  
90

  
91
def test_flatten_array_schema():
92
    schema = {
93
        'type': 'array',
94
        'items': {
95
            'type': 'object',
96
            'properties': {
97
                'a': {
98
                    'type': 'string',
99
                },
100
                'b': {
101
                    'type': 'integer',
102
                },
103
                'c': {
104
                    'type': 'array',
105
                    'items': {
106
                        'type': 'integer',
107
                    }
108
                }
109
            },
110
            'additionalProperties': False,
111
        }
112
    }
113
    flattened_schema = flatten_json_schema(schema)
114
    data = [
115
        {'a': 'a', 'b': 1, 'c': [1, 2, 3]},
116
        {'a': 'a', 'b': 1, 'c': [1, 2, 3]},
117
        {'a': 'a', 'b': 1, 'c': [1, 2, 3]},
118
    ]
119
    flattened_data = flatten(data)
120

  
121
    jsonschema.validate(schema=schema, instance=data)
122
    assert flattened_schema == {
123
        'type': 'object',
124
        'properties': {
125
            '0' + SEP + 'a': {'type': 'string'},
126
            '0' + SEP + 'b': {'type': 'integer'},
127
            '0' + SEP + 'c' + SEP + '0': {'type': 'integer'},
128
            '0' + SEP + 'c' + SEP + '1': {'type': 'integer'},
129
            '0' + SEP + 'c' + SEP + '2': {'type': 'integer'},
130
            '1' + SEP + 'a': {'type': 'string'},
131
            '1' + SEP + 'b': {'type': 'integer'},
132
            '1' + SEP + 'c' + SEP + '0': {'type': 'integer'},
133
            '1' + SEP + 'c' + SEP + '1': {'type': 'integer'},
134
            '1' + SEP + 'c' + SEP + '2': {'type': 'integer'},
135
            '2' + SEP + 'a': {'type': 'string'},
136
            '2' + SEP + 'b': {'type': 'integer'},
137
            '2' + SEP + 'c' + SEP + '0': {'type': 'integer'},
138
            '2' + SEP + 'c' + SEP + '1': {'type': 'integer'},
139
            '2' + SEP + 'c' + SEP + '2': {'type': 'integer'},
140
        },
141
        'additionalProperties': False,
142
    }
143
    # This should never be done as we cannot really validate all keys
144
    # containing array indexes, here it works because array have less than 3
145
    # elements.
146
    jsonschema.validate(schema=flattened_schema, instance=flattened_data)
147
    assert data == unflatten(flattened_data)
148

  
149

  
150
def test_flatten_dict_schema():
151
    assert flatten_json_schema({
152
        'type': 'object',
153
        'properties': {
154
            'a': {
155
                'type': 'string',
156
            },
157
            'b': {
158
                'type': 'integer',
159
            },
160
            'c': {
161
                'type': 'array',
162
                'items': {
163
                    'type': 'integer',
164
                }
165
            }
166
        }
167
    }) == {
168
        'type': 'object',
169
        'properties': {
170
            'a': {'type': 'string'},
171
            'b': {'type': 'integer'},
172
            'c' + SEP + '0': {'type': 'integer'},
173
            'c' + SEP + '1': {'type': 'integer'},
174
            'c' + SEP + '2': {'type': 'integer'},
175
        },
176
        'additionalProperties': False,
177
    }
0
-