Projet

Général

Profil

0001-utils-add-zip-package-for-templated-zip-files-36848.patch

Benjamin Dauvergne, 15 octobre 2019 14:08

Télécharger (11,1 ko)

Voir les différences:

Subject: [PATCH 1/3] utils: add zip package for templated zip files (#36848)

 passerelle/utils/zip.py | 180 ++++++++++++++++++++++++++++++++++++++++
 tests/test_utils_zip.py | 113 +++++++++++++++++++++++++
 2 files changed, 293 insertions(+)
 create mode 100644 passerelle/utils/zip.py
 create mode 100644 tests/test_utils_zip.py
passerelle/utils/zip.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

  
17
from __future__ import unicode_literals, absolute_import
18

  
19
import io
20
import os.path
21
import json
22
import re
23
import xml.etree.ElementTree as ET
24
import zipfile
25

  
26
from jsonschema import validate, ValidationError
27

  
28
from django.template import Template, Context, TemplateDoesNotExist, TemplateSyntaxError, engines
29
from django.utils.functional import cached_property
30
from django.template.loader import get_template
31

  
32
from passerelle.utils.files import atomic_write
33

  
34

  
35
SCHEMA = {
36
    'type': 'object',
37
    'required': ['name_template'],
38
    'properties': {
39
        'name_template': {
40
            'type': 'string',
41
        },
42
        'part_templates': {
43
            'type': 'array',
44
            'items': {
45
                'type': 'object',
46
                'required': ['name_template', 'template_path'],
47
                'properties': {
48
                    'name_template': {
49
                        'type': 'string',
50
                    },
51
                    'template_path': {
52
                        'type': 'string',
53
                    },
54
                },
55
            },
56
        },
57
    },
58
}
59

  
60

  
61
class ZipTemplateError(Exception):
62
    pass
63

  
64

  
65
class ZipTemplateDoesNotExist(ZipTemplateError):
66
    pass
67

  
68

  
69
class ZipTemplateSyntaxError(ZipTemplateError):
70
    pass
71

  
72
VARIABLE_RE = re.compile(r'{{ *(\w*)')
73

  
74

  
75
class ZipTemplate(object):
76
    def __init__(self, manifest, ctx=None):
77
        path = None
78
        for engine in engines.all():
79
            for loader in engine.engine.template_loaders:
80
                for origin in loader.get_template_sources(manifest):
81
                    if os.path.exists(origin.name):
82
                        path = origin.name
83
                        break
84
                if path:
85
                    break
86
            if path:
87
                break
88
        if not path:
89
            raise ZipTemplateDoesNotExist('manifest %s not found' % manifest)
90
        self.base_path = os.path.dirname(manifest)
91
        self.manifest_path = path
92
        try:
93
            manifest = self.manifest
94
        except ValueError as e:
95
            raise ZipTemplateError('invalid manifest file %s' % path, e)
96
        try:
97
            validate(self.manifest, SCHEMA)
98
        except ValidationError as e:
99
            raise ZipTemplateError('invalid manifest file %s' % path, e)
100
        self.ctx = ctx or {}
101

  
102
    @cached_property
103
    def manifest(self):
104
        with open(self.manifest_path) as fd:
105
            return json.load(fd)
106

  
107
    def __render_template(self, template, origin):
108
        try:
109
            return template.render(Context(self.ctx, use_l10n=False))
110
        except TemplateSyntaxError as e:
111
            raise ZipTemplateSyntaxError(e)
112

  
113
    @property
114
    def name_template(self):
115
        try:
116
            return Template(self.manifest['name_template'])
117
        except TemplateSyntaxError as e:
118
            raise ZipTemplateSyntaxError(e)
119

  
120
    @property
121
    def name(self):
122
        return self.__render_template(self.name_template, '<name_template>')
123

  
124
    @property
125
    def parts(self):
126
        for part_template in self.manifest.get('part_templates', []):
127
            try:
128
                name_template = Template(part_template['name_template'])
129
            except TemplateSyntaxError as e:
130
                raise ZipTemplateSyntaxError('syntax error in part\'s name template %s' % part_template, e)
131

  
132
            template_path = os.path.join(self.base_path, part_template['template_path'])
133
            try:
134
                template = get_template(template_path)
135
            except TemplateSyntaxError as e:
136
                raise ZipTemplateSyntaxError('syntax error in part template %s' % template_path, e)
137
            except TemplateDoesNotExist as e:
138
                raise ZipTemplateDoesNotExist('part template %s not found' % template_path, e)
139
            yield name_template, template, template_path, part_template
140

  
141
    @property
142
    def rendered_parts(self):
143
        for name_template, template, template_path, part_template in self.parts:
144
            name = self.__render_template(name_template, part_template)
145
            content = template.render(self.ctx)
146
            if name.endswith('.xml'):
147
                try:
148
                    ET.fromstring(content)
149
                except ET.ParseError as e:
150
                    raise ZipTemplateSyntaxError('XML syntax error in part template %s' % template_path, e)
151
            yield name, content
152

  
153
    def render_to_bytes(self):
154
        with io.BytesIO() as buf:
155
            self.render_to_file(buf)
156
            return buf.getvalue()
157

  
158
    def render_to_file(self, filelike):
159
        with zipfile.ZipFile(filelike, 'w') as zi:
160
            for name, content in self.rendered_parts:
161
                zi.writestr(name, content)
162

  
163
    def render_to_path(self, path, tmp_dir=None):
164
        full_path = os.path.join(str(path), self.name)
165
        with atomic_write(full_path, dir=tmp_dir) as fd:
166
            self.render_to_file(fd)
167

  
168
    @staticmethod
169
    def __variables_from_template(template):
170
        return set(VARIABLE_RE.findall(
171
            (getattr(template, 'template', None) or template).source))
172

  
173
    @property
174
    def variables(self):
175
        found = set()
176
        found.update(self.__variables_from_template(self.name_template))
177
        for name_template, template, template_path, part_template in self.parts:
178
            found.update(self.__variables_from_template(name_template))
179
            found.update(self.__variables_from_template(template))
180
        return found
tests/test_utils_zip.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

  
17
from __future__ import unicode_literals
18

  
19
import json
20
import uuid
21
import zipfile
22

  
23
import pytest
24

  
25
from passerelle.utils.zip import ZipTemplate, ZipTemplateDoesNotExist, ZipTemplateSyntaxError
26

  
27

  
28
@pytest.fixture
29
def templates_path(tmpdir, settings):
30
    path = tmpdir.mkdir('templates')
31
    settings.TEMPLATES = settings.TEMPLATES[:]
32
    settings.TEMPLATES[0] = settings.TEMPLATES[0].copy()
33
    settings.TEMPLATES[0].setdefault('DIRS', [])
34
    settings.TEMPLATES[0]['DIRS'].insert(0, str(path))
35
    zip_templates_path = path.mkdir('zip_templates')
36
    return zip_templates_path
37

  
38

  
39
@pytest.fixture
40
def tpl_builder(templates_path):
41
    def make(name_template, *parts):
42
        manifest_name = '%s.json' % uuid.uuid4().get_hex()
43
        manifest_path = templates_path / manifest_name
44
        d = {
45
            'name_template': name_template,
46
        }
47
        if parts:
48
            d['part_templates'] = []
49
            for name_template, content in parts:
50
                name = '%s.xml' % uuid.uuid4().get_hex()
51
                with (templates_path / name).open('w') as fd:
52
                    fd.write(content)
53
                d['part_templates'].append({
54
                    'name_template': name_template,
55
                    'template_path': name,
56
                })
57
        with manifest_path.open('w') as fd:
58
            json.dump(d, fd)
59
        return '%s/%s' % (templates_path.basename, manifest_name)
60
    return make
61

  
62

  
63
@pytest.fixture
64
def dest(tmpdir):
65
    return tmpdir.mkdir('dest')
66

  
67

  
68
def test_missing(templates_path):
69
    with pytest.raises(ZipTemplateDoesNotExist):
70
        ZipTemplate('zip_templates/manifest1.json')
71

  
72

  
73
def test_syntax_error(tpl_builder, dest):
74
    zip_template = ZipTemplate(tpl_builder('{{ name -{{ counter }}.zip'), ctx={'name': 'coucou', 'counter': 10})
75
    with pytest.raises(ZipTemplateSyntaxError):
76
        zip_template.render_to_path(dest)
77

  
78
    zip_template = ZipTemplate(
79
        tpl_builder(
80
            '{{ name }}-{{ counter }}.zip',
81
            ('part1.xml', '{{ name {{ }}')),
82
        ctx={'name': 'coucou', 'counter': 10})
83
    with pytest.raises(ZipTemplateSyntaxError):
84
        zip_template.render_to_path(dest)
85

  
86

  
87
def test_no_parts(tpl_builder, dest):
88
    z = ZipTemplate(tpl_builder('{{ name }}-{{ counter }}.zip'),
89
                    ctx={'name': 'coucou', 'counter': 10})
90
    z.render_to_path(dest)
91

  
92
    assert z.variables == set(['name', 'counter'])
93
    full_path = dest / 'coucou-10.zip'
94
    with full_path.open() as fd:
95
        with zipfile.ZipFile(fd) as zi:
96
            assert zi.namelist() == []
97

  
98

  
99
def test_with_parts(tpl_builder, dest):
100
    z = ZipTemplate(
101
        tpl_builder(
102
            '{{ name }}-{{ counter }}.zip',
103
            ('{{ name }}-{{ counter }}-part1.xml', '<?xml version="1.0"?><body>{{ bo_dy|lower }}</body>'),
104
        ),
105
        ctx={'name': 'coucou', 'counter': 10, 'bo_dy': 'blabla'})
106
    assert z.variables == set(['name', 'counter', 'bo_dy'])
107
    z.render_to_path(dest)
108

  
109
    full_path = dest / 'coucou-10.zip'
110
    with full_path.open() as fd:
111
        with zipfile.ZipFile(fd) as zi:
112
            assert zi.namelist() == ['coucou-10-part1.xml']
113
            assert zi.open('coucou-10-part1.xml').read() == '<?xml version="1.0"?><body>blabla</body>'
0
-