Project

General

Profile

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

hydrilla-builder / src / hydrilla / builder / build.py @ 136859ca

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
from . import _version
39

    
40
here = Path(__file__).resolve().parent
41

    
42
_ = util.translation(here / 'locales').gettext
43

    
44
index_validator = util.validator_for('package_source-1.schema.json')
45

    
46
schemas_root = 'https://hydrilla.koszko.org/schemas'
47

    
48
generated_by = {
49
    'name': 'hydrilla.builder',
50
    'version': _version.version
51
}
52

    
53
class FileReferenceError(Exception):
54
    """
55
    Exception used to report various problems concerning files referenced from
56
    source package's index.json.
57
    """
58

    
59
class ReuseError(Exception):
60
    """
61
    Exception used to report various problems when calling the REUSE tool.
62
    """
63

    
64
class FileBuffer:
65
    """
66
    Implement a file-like object that buffers data written to it.
67
    """
68
    def __init__(self):
69
        """
70
        Initialize FileBuffer.
71
        """
72
        self.chunks = []
73

    
74
    def write(self, b):
75
        """
76
        Buffer 'b', return number of bytes buffered.
77

    
78
        'b' is expected to be an instance of 'bytes' or 'str', in which case it
79
        gets encoded as UTF-8.
80
        """
81
        if type(b) is str:
82
            b = b.encode()
83
        self.chunks.append(b)
84
        return len(b)
85

    
86
    def flush(self):
87
        """
88
        A no-op mock of file-like object's flush() method.
89
        """
90
        pass
91

    
92
    def get_bytes(self):
93
        """
94
        Return all data written so far concatenated into a single 'bytes'
95
        object.
96
        """
97
        return b''.join(self.chunks)
98

    
99
def generate_spdx_report(root):
100
    """
101
    Use REUSE tool to generate an SPDX report for sources under 'root' and
102
    return the report's contents as 'bytes'.
103

    
104
    'root' shall be an instance of pathlib.Path.
105

    
106
    In case the directory tree under 'root' does not constitute a
107
    REUSE-compliant package, linting report is printed to standard output and
108
    an exception is raised.
109

    
110
    In case the reuse package is not installed, an exception is also raised.
111
    """
112
    try:
113
        from reuse._main import main as reuse_main
114
    except ModuleNotFoundError:
115
        ReuseError(_('couldnt_import_reuse_is_it_installed'))
116

    
117
    mocked_output = FileBuffer()
118
    if reuse_main(args=['--root', str(root), 'lint'], out=mocked_output) != 0:
119
        stderr.write(mocked_output.get_bytes().decode())
120
        raise ReuseError(_('spdx_report_from_reuse_incompliant'))
121

    
122
    mocked_output = FileBuffer()
123
    if reuse_main(args=['--root', str(root), 'spdx'], out=mocked_output) != 0:
124
        stderr.write(mocked_output.get_bytes().decode())
125
        raise ReuseError("Couldn't generate an SPDX report for package.")
126

    
127
    return mocked_output.get_bytes()
128

    
129
class FileRef:
130
    """Represent reference to a file in the package."""
131
    def __init__(self, path: Path, contents: bytes):
132
        """Initialize FileRef."""
133
        self.include_in_distribution = False
134
        self.include_in_zipfile      = True
135
        self.path                    = path
136
        self.contents                = contents
137

    
138
        self.contents_hash = sha256(contents).digest().hex()
139

    
140
    def make_ref_dict(self, filename: str):
141
        """
142
        Represent the file reference through a dict that can be included in JSON
143
        defintions.
144
        """
145
        return {
146
            'file':   filename,
147
            'sha256': self.contents_hash
148
        }
149

    
150
class Build:
151
    """
152
    Build a Hydrilla package.
153
    """
154
    def __init__(self, srcdir, index_json_path):
155
        """
156
        Initialize a build. All files to be included in a distribution package
157
        are loaded into memory, all data gets validated and all necessary
158
        computations (e.g. preparing of hashes) are performed.
159

    
160
        'srcdir' and 'index_json' are expected to be pathlib.Path objects.
161
        """
162
        self.srcdir          = srcdir.resolve()
163
        self.index_json_path = index_json_path
164
        self.files_by_path   = {}
165
        self.resource_list   = []
166
        self.mapping_list    = []
167

    
168
        if not index_json_path.is_absolute():
169
            self.index_json_path = (self.srcdir / self.index_json_path)
170

    
171
        self.index_json_path = self.index_json_path.resolve()
172

    
173
        with open(self.index_json_path, 'rt') as index_file:
174
            index_json_text = index_file.read()
175

    
176
        index_obj = json.loads(util.strip_json_comments(index_json_text))
177

    
178
        self.files_by_path[self.srcdir / 'index.json'] = \
179
            FileRef(self.srcdir / 'index.json', index_json_text.encode())
180

    
181
        self._process_index_json(index_obj)
182

    
183
    def _process_file(self, filename: str, include_in_distribution: bool=True):
184
        """
185
        Resolve 'filename' relative to srcdir, load it to memory (if not loaded
186
        before), compute its hash and store its information in
187
        'self.files_by_path'.
188

    
189
        'filename' shall represent a relative path using '/' as a separator.
190

    
191
        if 'include_in_distribution' is True it shall cause the file to not only
192
        be included in the source package's zipfile, but also written as one of
193
        built package's files.
194

    
195
        Return file's reference object that can be included in JSON defintions
196
        of various kinds.
197
        """
198
        path = self.srcdir
199
        for segment in filename.split('/'):
200
            path /= segment
201

    
202
        path = path.resolve()
203
        if not path.is_relative_to(self.srcdir):
204
            raise FileReferenceError(_('loading_{}_outside_package_dir')
205
                                     .format(filename))
206

    
207
        if str(path.relative_to(self.srcdir)) == 'index.json':
208
            raise FileReferenceError(_('loading_reserved_index_json'))
209

    
210
        file_ref = self.files_by_path.get(path)
211
        if file_ref is None:
212
            with open(path, 'rb') as file_handle:
213
                contents = file_handle.read()
214

    
215
            file_ref = FileRef(path, contents)
216
            self.files_by_path[path] = file_ref
217

    
218
        if include_in_distribution:
219
            file_ref.include_in_distribution = True
220

    
221
        return file_ref.make_ref_dict(filename)
222

    
223
    def _prepare_source_package_zip(self, root_dir_name: str):
224
        """
225
        Create and store in memory a .zip archive containing files needed to
226
        build this source package.
227

    
228
        'root_dir_name' shall not contain any slashes ('/').
229

    
230
        Return zipfile's sha256 sum's hexstring.
231
        """
232
        fb = FileBuffer()
233
        root_dir_path = Path(root_dir_name)
234

    
235
        def zippath(file_path):
236
            file_path = root_dir_path / file_path.relative_to(self.srcdir)
237
            return file_path.as_posix()
238

    
239
        with zipfile.ZipFile(fb, 'w') as xpi:
240
            for file_ref in self.files_by_path.values():
241
                if file_ref.include_in_zipfile:
242
                    xpi.writestr(zippath(file_ref.path), file_ref.contents)
243

    
244
        self.source_zip_contents = fb.get_bytes()
245

    
246
        return sha256(self.source_zip_contents).digest().hex()
247

    
248
    def _process_item(self, item_def: dict):
249
        """
250
        Process 'item_def' as definition of a resource/mapping and store in
251
        memory its processed form and files used by it.
252

    
253
        Return a minimal item reference suitable for using in source
254
        description.
255
        """
256
        copy_props = ['type', 'identifier', 'long_name', 'uuid', 'description']
257
        if 'comment' in item_def:
258
            copy_props.append('comment')
259

    
260
        if item_def['type'] == 'resource':
261
            item_list = self.resource_list
262

    
263
            copy_props.append('revision')
264

    
265
            script_file_refs = [self._process_file(f['file'])
266
                                for f in item_def.get('scripts', [])]
267

    
268
            deps = [{'identifier': res_ref['identifier']}
269
                    for res_ref in item_def.get('dependencies', [])]
270

    
271
            new_item_obj = {
272
                'dependencies': deps,
273
                'scripts':      script_file_refs
274
            }
275
        else:
276
            item_list = self.mapping_list
277

    
278
            payloads = {}
279
            for pat, res_ref in item_def.get('payloads', {}).items():
280
                payloads[pat] = {'identifier': res_ref['identifier']}
281

    
282
            new_item_obj = {
283
                'payloads': payloads
284
            }
285

    
286
        new_item_obj.update([(p, item_def[p]) for p in copy_props])
287

    
288
        new_item_obj['version'] = util.normalize_version(item_def['version'])
289
        new_item_obj['$schema'] = f'{schemas_root}/api_{item_def["type"]}_description-1.schema.json'
290
        new_item_obj['source_copyright'] = self.copyright_file_refs
291
        new_item_obj['source_name'] = self.source_name
292
        new_item_obj['generated_by'] = generated_by
293

    
294
        item_list.append(new_item_obj)
295

    
296
        props_in_ref = ('type', 'identifier', 'version', 'long_name')
297
        return dict([(prop, new_item_obj[prop]) for prop in props_in_ref])
298

    
299
    def _process_index_json(self, index_obj: dict):
300
        """
301
        Process 'index_obj' as contents of source package's index.json and store
302
        in memory this source package's zipfile as well as package's individual
303
        files and computed definitions of the source package and items defined
304
        in it.
305
        """
306
        index_validator.validate(index_obj)
307

    
308
        schema = f'{schemas_root}/api_source_description-1.schema.json'
309

    
310
        self.source_name = index_obj['source_name']
311

    
312
        generate_spdx = index_obj.get('reuse_generate_spdx_report', False)
313
        if generate_spdx:
314
            contents  = generate_spdx_report(self.srcdir)
315
            spdx_path = (self.srcdir / 'report.spdx').resolve()
316
            spdx_ref  = FileRef(spdx_path, contents)
317

    
318
            spdx_ref.include_in_zipfile = False
319
            self.files_by_path[spdx_path] = spdx_ref
320

    
321
        self.copyright_file_refs = \
322
            [self._process_file(f['file']) for f in index_obj['copyright']]
323

    
324
        if generate_spdx and not spdx_ref.include_in_distribution:
325
            raise FileReferenceError(_('report_spdx_not_in_copyright_list'))
326

    
327
        item_refs = [self._process_item(d) for d in index_obj['definitions']]
328

    
329
        for file_ref in index_obj.get('additional_files', []):
330
            self._process_file(file_ref['file'], include_in_distribution=False)
331

    
332
        root_dir_path = Path(self.source_name)
333

    
334
        source_archives_obj = {
335
            'zip' : {
336
                'sha256': self._prepare_source_package_zip(root_dir_path)
337
            }
338
        }
339

    
340
        self.source_description = {
341
            '$schema':            schema,
342
            'source_name':        self.source_name,
343
            'source_copyright':   self.copyright_file_refs,
344
            'upstream_url':       index_obj['upstream_url'],
345
            'definitions':        item_refs,
346
            'source_archives':    source_archives_obj,
347
            'generated_by':       generated_by
348
        }
349

    
350
        if 'comment' in index_obj:
351
            self.source_description['comment'] = index_obj['comment']
352

    
353
    def write_source_package_zip(self, dstpath: Path):
354
        """
355
        Create a .zip archive containing files needed to build this source
356
        package and write it at 'dstpath'.
357
        """
358
        with open(dstpath, 'wb') as output:
359
            output.write(self.source_zip_contents)
360

    
361
    def write_package_files(self, dstpath: Path):
362
        """Write package files under 'dstpath' for distribution."""
363
        file_dir_path = (dstpath / 'file' / 'sha256').resolve()
364
        file_dir_path.mkdir(parents=True, exist_ok=True)
365

    
366
        for file_ref in self.files_by_path.values():
367
            if file_ref.include_in_distribution:
368
                file_path = file_dir_path / file_ref.contents_hash
369
                file_path.write_bytes(file_ref.contents)
370

    
371
        source_dir_path = (dstpath / 'source').resolve()
372
        source_dir_path.mkdir(parents=True, exist_ok=True)
373
        source_name = self.source_description["source_name"]
374

    
375
        with open(source_dir_path / f'{source_name}.json', 'wt') as output:
376
            json.dump(self.source_description, output)
377

    
378
        with open(source_dir_path / f'{source_name}.zip', 'wb') as output:
379
            output.write(self.source_zip_contents)
380

    
381
        for item_type, item_list in [
382
                ('resource', self.resource_list),
383
                ('mapping', self.mapping_list)
384
        ]:
385
            item_type_dir_path = (dstpath / item_type).resolve()
386

    
387
            for item_def in item_list:
388
                item_dir_path = item_type_dir_path / item_def['identifier']
389
                item_dir_path.mkdir(parents=True, exist_ok=True)
390

    
391
                version = '.'.join([str(n) for n in item_def['version']])
392
                with open(item_dir_path / version, 'wt') as output:
393
                    json.dump(item_def, output)
394

    
395
dir_type = click.Path(exists=True, file_okay=False, resolve_path=True)
396

    
397
@click.option('-s', '--srcdir', default='./', type=dir_type, show_default=True,
398
              help=_('source_directory_to_build_from'))
399
@click.option('-i', '--index-json', default='index.json', type=click.Path(),
400
              help=_('path_instead_of_index_json'))
401
@click.option('-d', '--dstdir', type=dir_type, required=True,
402
              help=_('built_package_files_destination'))
403
def perform(srcdir, index_json, dstdir):
404
    """<this will be replaced by a localized docstring for Click to pick up>"""
405
    build = Build(Path(srcdir), Path(index_json))
406
    build.write_package_files(Path(dstdir))
407

    
408
perform.__doc__ = _('build_package_from_srcdir_to_dstdir')
409

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