Project

General

Profile

Download (13.7 KB) Statistics
| Branch: | Tag: | Revision:

hydrilla-builder / src / hydrilla / builder / build.py @ 403ca642

1
# SPDX-License-Identifier: AGPL-3.0-or-later
2

    
3
# Building Hydrilla packages.
4
#
5
# This file is part of Hydrilla
6
#
7
# Copyright (C) 2022 Wojtek Kosior
8
#
9
# This program is free software: you can redistribute it and/or modify
10
# it under the terms of the GNU Affero General Public License as
11
# published by the Free Software Foundation, either version 3 of the
12
# License, or (at your option) any later version.
13
#
14
# This program is distributed in the hope that it will be useful,
15
# but WITHOUT ANY WARRANTY; without even the implied warranty of
16
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
# GNU Affero General Public License for more details.
18
#
19
# You should have received a copy of the GNU Affero General Public License
20
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
21
#
22
#
23
# I, Wojtek Kosior, thereby promise not to sue for violation of this
24
# file's license. Although I request that you do not make use this code
25
# in a proprietary program, I am not going to enforce this in court.
26

    
27
import json
28
import re
29
import zipfile
30
from pathlib import Path
31
from hashlib import sha256
32
from sys import stderr
33

    
34
import jsonschema
35
import click
36

    
37
from .. import util
38

    
39
_ = util.translation('hydrilla_builder').gettext
40

    
41
index_validator = util.validator_for('package_source-1.schema.json')
42

    
43
class FileReferenceError(Exception):
44
    """
45
    Exception used to report various problems concerning files referenced from
46
    source package's index.json.
47
    """
48

    
49
class ReuseError(Exception):
50
    """
51
    Exception used to report various problems when calling the REUSE tool.
52
    """
53

    
54
class FileBuffer:
55
    """
56
    Implement a file-like object that buffers data written to it.
57
    """
58
    def __init__(self):
59
        """
60
        Initialize FileBuffer.
61
        """
62
        self.chunks = []
63

    
64
    def write(self, b):
65
        """
66
        Buffer 'b', return number of bytes buffered.
67

    
68
        'b' is expected to be an instance of 'bytes' or 'str', in which case it
69
        gets encoded as UTF-8.
70
        """
71
        if type(b) is str:
72
            b = b.encode()
73
        self.chunks.append(b)
74
        return len(b)
75

    
76
    def flush(self):
77
        """
78
        A no-op mock of file-like object's flush() method.
79
        """
80
        pass
81

    
82
    def get_bytes(self):
83
        """
84
        Return all data written so far concatenated into a single 'bytes'
85
        object.
86
        """
87
        return b''.join(self.chunks)
88

    
89
def generate_spdx_report(root):
90
    """
91
    Use REUSE tool to generate an SPDX report for sources under 'root' and
92
    return the report's contents as 'bytes'.
93

    
94
    'root' shall be an instance of pathlib.Path.
95

    
96
    In case the directory tree under 'root' does not constitute a
97
    REUSE-compliant package, linting report is printed to standard output and
98
    an exception is raised.
99

    
100
    In case the reuse package is not installed, an exception is also raised.
101
    """
102
    try:
103
        from reuse._main import main as reuse_main
104
    except ModuleNotFoundError:
105
        ReuseError(_('couldnt_import_reuse_is_it_installed'))
106

    
107
    mocked_output = FileBuffer()
108
    if reuse_main(args=['--root', str(root), 'lint'], out=mocked_output) != 0:
109
        stderr.write(mocked_output.get_bytes().decode())
110
        raise ReuseError(_('spdx_report_from_reuse_incompliant'))
111

    
112
    mocked_output = FileBuffer()
113
    if reuse_main(args=['--root', str(root), 'spdx'], out=mocked_output) != 0:
114
        stderr.write(mocked_output.get_bytes().decode())
115
        raise ReuseError("Couldn't generate an SPDX report for package.")
116

    
117
    return mocked_output.get_bytes()
118

    
119
class FileRef:
120
    """Represent reference to a file in the package."""
121
    def __init__(self, path: Path, contents: bytes):
122
        """Initialize FileRef."""
123
        self.include_in_distribution = False
124
        self.include_in_zipfile      = True
125
        self.path                    = path
126
        self.contents                = contents
127

    
128
        self.contents_hash = sha256(contents).digest().hex()
129

    
130
    def make_ref_dict(self, filename: str):
131
        """
132
        Represent the file reference through a dict that can be included in JSON
133
        defintions.
134
        """
135
        return {
136
            'file':   filename,
137
            'sha256': self.contents_hash
138
        }
139

    
140
class Build:
141
    """
142
    Build a Hydrilla package.
143
    """
144
    def __init__(self, srcdir, index_json_path):
145
        """
146
        Initialize a build. All files to be included in a distribution package
147
        are loaded into memory, all data gets validated and all necessary
148
        computations (e.g. preparing of hashes) are performed.
149

    
150
        'srcdir' and 'index_json' are expected to be pathlib.Path objects.
151
        """
152
        self.srcdir          = srcdir.resolve()
153
        self.index_json_path = index_json_path
154
        self.files_by_path   = {}
155
        self.resource_list   = []
156
        self.mapping_list    = []
157

    
158
        if not index_json_path.is_absolute():
159
            self.index_json_path = (self.srcdir / self.index_json_path)
160

    
161
        self.index_json_path = self.index_json_path.resolve()
162

    
163
        with open(self.index_json_path, 'rt') as index_file:
164
            index_json_text = index_file.read()
165

    
166
        index_obj = json.loads(util.strip_json_comments(index_json_text))
167

    
168
        self.files_by_path[self.srcdir / 'index.json'] = \
169
            FileRef(self.srcdir / 'index.json', index_json_text.encode())
170

    
171
        self._process_index_json(index_obj)
172

    
173
    def _process_file(self, filename: str, include_in_distribution: bool=True):
174
        """
175
        Resolve 'filename' relative to srcdir, load it to memory (if not loaded
176
        before), compute its hash and store its information in
177
        'self.files_by_path'.
178

    
179
        'filename' shall represent a relative path using '/' as a separator.
180

    
181
        if 'include_in_distribution' is True it shall cause the file to not only
182
        be included in the source package's zipfile, but also written as one of
183
        built package's files.
184

    
185
        Return file's reference object that can be included in JSON defintions
186
        of various kinds.
187
        """
188
        path = self.srcdir
189
        for segment in filename.split('/'):
190
            path /= segment
191

    
192
        path = path.resolve()
193
        if not path.is_relative_to(self.srcdir):
194
            raise FileReferenceError(_('loading_{}_outside_package_dir')
195
                                     .format(filename))
196

    
197
        if str(path.relative_to(self.srcdir)) == 'index.json':
198
            raise FileReferenceError(_('loading_reserved_index_json'))
199

    
200
        file_ref = self.files_by_path.get(path)
201
        if file_ref is None:
202
            with open(path, 'rb') as file_handle:
203
                contents = file_handle.read()
204

    
205
            file_ref = FileRef(path, contents)
206
            self.files_by_path[path] = file_ref
207

    
208
        if include_in_distribution:
209
            file_ref.include_in_distribution = True
210

    
211
        return file_ref.make_ref_dict(filename)
212

    
213
    def _prepare_source_package_zip(self, root_dir_name: str):
214
        """
215
        Create and store in memory a .zip archive containing files needed to
216
        build this source package.
217

    
218
        'root_dir_name' shall not contain any slashes ('/').
219

    
220
        Return zipfile's sha256 sum's hexstring.
221
        """
222
        fb = FileBuffer()
223
        root_dir_path = Path(root_dir_name)
224

    
225
        def zippath(file_path):
226
            file_path = root_dir_path / file_path.relative_to(self.srcdir)
227
            return file_path.as_posix()
228

    
229
        with zipfile.ZipFile(fb, 'w') as xpi:
230
            for file_ref in self.files_by_path.values():
231
                if file_ref.include_in_zipfile:
232
                    xpi.writestr(zippath(file_ref.path), file_ref.contents)
233

    
234
        self.source_zip_contents = fb.get_bytes()
235

    
236
        return sha256(self.source_zip_contents).digest().hex()
237

    
238
    def _process_item(self, item_def: dict):
239
        """
240
        Process 'item_def' as definition of a resource/mapping and store in
241
        memory its processed form and files used by it.
242

    
243
        Return a minimal item reference suitable for using in source
244
        description.
245
        """
246
        copy_props = ['type', 'identifier', 'long_name', 'uuid', 'description']
247
        if 'comment' in item_def:
248
            copy_props.append('comment')
249

    
250
        if item_def['type'] == 'resource':
251
            item_list = self.resource_list
252

    
253
            copy_props.append('revision')
254

    
255
            script_file_refs = [self._process_file(f['file'])
256
                                for f in item_def.get('scripts', [])]
257

    
258
            deps = [{'identifier': res_ref['identifier']}
259
                    for res_ref in item_def.get('dependencies', [])]
260

    
261
            new_item_obj = {
262
                'dependencies': deps,
263
                'scripts':      script_file_refs
264
            }
265
        else:
266
            item_list = self.mapping_list
267

    
268
            payloads = {}
269
            for pat, res_ref in item_def.get('payloads', {}).items():
270
                payloads[pat] = {'identifier': res_ref['identifier']}
271

    
272
            new_item_obj = {
273
                'payloads': payloads
274
            }
275

    
276
        new_item_obj.update([(p, item_def[p]) for p in copy_props])
277

    
278
        new_item_obj['version'] = util.normalize_version(item_def['version'])
279
        new_item_obj['api_schema_version'] = [1, 0, 1]
280
        new_item_obj['source_copyright'] = self.copyright_file_refs
281
        new_item_obj['source_name'] = self.source_name
282

    
283
        item_list.append(new_item_obj)
284

    
285
        props_in_ref = ('type', 'identifier', 'version', 'long_name')
286
        return dict([(prop, new_item_obj[prop]) for prop in props_in_ref])
287

    
288
    def _process_index_json(self, index_obj: dict):
289
        """
290
        Process 'index_obj' as contents of source package's index.json and store
291
        in memory this source package's zipfile as well as package's individual
292
        files and computed definitions of the source package and items defined
293
        in it.
294
        """
295
        index_validator.validate(index_obj)
296

    
297
        self.source_name = index_obj['source_name']
298

    
299
        generate_spdx = index_obj.get('reuse_generate_spdx_report', False)
300
        if generate_spdx:
301
            contents  = generate_spdx_report(self.srcdir)
302
            spdx_path = (self.srcdir / 'report.spdx').resolve()
303
            spdx_ref  = FileRef(spdx_path, contents)
304

    
305
            spdx_ref.include_in_zipfile = False
306
            self.files_by_path[spdx_path] = spdx_ref
307

    
308
        self.copyright_file_refs = \
309
            [self._process_file(f['file']) for f in index_obj['copyright']]
310

    
311
        if generate_spdx and not spdx_ref.include_in_distribution:
312
            raise FileReferenceError(_('report_spdx_not_in_copyright_list'))
313

    
314
        item_refs = [self._process_item(d) for d in index_obj['definitions']]
315

    
316
        for file_ref in index_obj.get('additional_files', []):
317
            self._process_file(file_ref['file'], include_in_distribution=False)
318

    
319
        root_dir_path = Path(self.source_name)
320

    
321
        source_archives_obj = {
322
            'zip' : {
323
                'sha256': self._prepare_source_package_zip(root_dir_path)
324
            }
325
        }
326

    
327
        self.source_description = {
328
            'api_schema_version': [1, 0, 1],
329
            'source_name':        self.source_name,
330
            'source_copyright':   self.copyright_file_refs,
331
            'upstream_url':       index_obj['upstream_url'],
332
            'definitions':        item_refs,
333
            'source_archives':    source_archives_obj
334
        }
335

    
336
        if 'comment' in index_obj:
337
            self.source_description['comment'] = index_obj['comment']
338

    
339
    def write_source_package_zip(self, dstpath: Path):
340
        """
341
        Create a .zip archive containing files needed to build this source
342
        package and write it at 'dstpath'.
343
        """
344
        with open(dstpath, 'wb') as output:
345
            output.write(self.source_zip_contents)
346

    
347
    def write_package_files(self, dstpath: Path):
348
        """Write package files under 'dstpath' for distribution."""
349
        file_dir_path = (dstpath / 'file' / 'sha256').resolve()
350
        file_dir_path.mkdir(parents=True, exist_ok=True)
351

    
352
        for file_ref in self.files_by_path.values():
353
            if file_ref.include_in_distribution:
354
                file_path = file_dir_path / file_ref.contents_hash
355
                file_path.write_bytes(file_ref.contents)
356

    
357
        source_dir_path = (dstpath / 'source').resolve()
358
        source_dir_path.mkdir(parents=True, exist_ok=True)
359
        source_name = self.source_description["source_name"]
360

    
361
        with open(source_dir_path / f'{source_name}.json', 'wt') as output:
362
            json.dump(self.source_description, output)
363

    
364
        with open(source_dir_path / f'{source_name}.zip', 'wb') as output:
365
            output.write(self.source_zip_contents)
366

    
367
        for item_type, item_list in [
368
                ('resource', self.resource_list),
369
                ('mapping', self.mapping_list)
370
        ]:
371
            item_type_dir_path = (dstpath / item_type).resolve()
372

    
373
            for item_def in item_list:
374
                item_dir_path = item_type_dir_path / item_def['identifier']
375
                item_dir_path.mkdir(parents=True, exist_ok=True)
376

    
377
                version = '.'.join([str(n) for n in item_def['version']])
378
                with open(item_dir_path / version, 'wt') as output:
379
                    json.dump(item_def, output)
380

    
381
dir_type = click.Path(exists=True, file_okay=False, resolve_path=True)
382

    
383
@click.option('-s', '--srcdir', default='./', type=dir_type, show_default=True,
384
              help=_('source_directory_to_build_from'))
385
@click.option('-i', '--index-json', default='index.json', type=click.Path(),
386
              help=_('path_instead_of_index_json'))
387
@click.option('-d', '--dstdir', type=dir_type, required=True,
388
              help=_('built_package_files_destination'))
389
def perform(srcdir, index_json, dstdir):
390
    """<this will be replaced by a localized docstring for Click to pick up>"""
391
    build = Build(Path(srcdir), Path(index_json))
392
    build.write_package_files(Path(dstdir))
393

    
394
perform.__doc__ = _('build_package_from_srcdir_to_dstdir')
395

    
396
perform = click.command()(perform)
(3-3/3)