Project

General

Profile

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

hydrilla-builder / src / hydrilla / builder / build.py @ e6408637

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
# Enable using with Python 3.7.
28
from __future__ import annotations
29

    
30
import json
31
import re
32
import zipfile
33
from pathlib import Path
34
from hashlib import sha256
35
from sys import stderr
36

    
37
import jsonschema
38
import click
39

    
40
from .. import util
41
from . import _version
42

    
43
here = Path(__file__).resolve().parent
44

    
45
_ = util.translation(here / 'locales').gettext
46

    
47
index_validator = util.validator_for('package_source-1.0.1.schema.json')
48

    
49
schemas_root = 'https://hydrilla.koszko.org/schemas'
50

    
51
generated_by = {
52
    'name': 'hydrilla.builder',
53
    'version': _version.version
54
}
55

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

    
62
class ReuseError(Exception):
63
    """
64
    Exception used to report various problems when calling the REUSE tool.
65
    """
66

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

    
77
    def write(self, b):
78
        """
79
        Buffer 'b', return number of bytes buffered.
80

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

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

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

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

    
107
    'root' shall be an instance of pathlib.Path.
108

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

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

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

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

    
130
    return mocked_output.get_bytes()
131

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

    
141
        self.contents_hash = sha256(contents).digest().hex()
142

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

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

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

    
171
        if not index_json_path.is_absolute():
172
            self.index_json_path = (self.srcdir / self.index_json_path)
173

    
174
        self.index_json_path = self.index_json_path.resolve()
175

    
176
        with open(self.index_json_path, 'rt') as index_file:
177
            index_json_text = index_file.read()
178

    
179
        index_obj = json.loads(util.strip_json_comments(index_json_text))
180

    
181
        self.files_by_path[self.srcdir / 'index.json'] = \
182
            FileRef(self.srcdir / 'index.json', index_json_text.encode())
183

    
184
        self._process_index_json(index_obj)
185

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

    
192
        'filename' shall represent a relative path using '/' as a separator.
193

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

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

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

    
210
        if str(path.relative_to(self.srcdir)) == 'index.json':
211
            raise FileReferenceError(_('loading_reserved_index_json'))
212

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

    
218
            file_ref = FileRef(path, contents)
219
            self.files_by_path[path] = file_ref
220

    
221
        if include_in_distribution:
222
            file_ref.include_in_distribution = True
223

    
224
        return file_ref.make_ref_dict(filename)
225

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

    
231
        'root_dir_name' shall not contain any slashes ('/').
232

    
233
        Return zipfile's sha256 sum's hexstring.
234
        """
235
        fb = FileBuffer()
236
        root_dir_path = Path(root_dir_name)
237

    
238
        def zippath(file_path):
239
            file_path = root_dir_path / file_path.relative_to(self.srcdir)
240
            return file_path.as_posix()
241

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

    
247
        self.source_zip_contents = fb.get_bytes()
248

    
249
        return sha256(self.source_zip_contents).digest().hex()
250

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

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

    
264
        if item_def['type'] == 'resource':
265
            item_list = self.resource_list
266

    
267
            copy_props.append('revision')
268

    
269
            script_file_refs = [self._process_file(f['file'])
270
                                for f in item_def.get('scripts', [])]
271

    
272
            deps = [{'identifier': res_ref['identifier']}
273
                    for res_ref in item_def.get('dependencies', [])]
274

    
275
            new_item_obj = {
276
                'dependencies': deps,
277
                'scripts':      script_file_refs
278
            }
279
        else:
280
            item_list = self.mapping_list
281

    
282
            payloads = {}
283
            for pat, res_ref in item_def.get('payloads', {}).items():
284
                payloads[pat] = {'identifier': res_ref['identifier']}
285

    
286
            new_item_obj = {
287
                'payloads': payloads
288
            }
289

    
290
        new_item_obj.update([(p, item_def[p]) for p in copy_props])
291

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

    
298
        item_list.append(new_item_obj)
299

    
300
        props_in_ref = ('type', 'identifier', 'version', 'long_name')
301
        return dict([(prop, new_item_obj[prop]) for prop in props_in_ref])
302

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

    
312
        schema = f'{schemas_root}/api_source_description-1.schema.json'
313

    
314
        self.source_name = index_obj['source_name']
315

    
316
        generate_spdx = index_obj.get('reuse_generate_spdx_report', False)
317
        if generate_spdx:
318
            contents  = generate_spdx_report(self.srcdir)
319
            spdx_path = (self.srcdir / 'report.spdx').resolve()
320
            spdx_ref  = FileRef(spdx_path, contents)
321

    
322
            spdx_ref.include_in_zipfile = False
323
            self.files_by_path[spdx_path] = spdx_ref
324

    
325
        self.copyright_file_refs = \
326
            [self._process_file(f['file']) for f in index_obj['copyright']]
327

    
328
        if generate_spdx and not spdx_ref.include_in_distribution:
329
            raise FileReferenceError(_('report_spdx_not_in_copyright_list'))
330

    
331
        item_refs = [self._process_item(d) for d in index_obj['definitions']]
332

    
333
        for file_ref in index_obj.get('additional_files', []):
334
            self._process_file(file_ref['file'], include_in_distribution=False)
335

    
336
        root_dir_path = Path(self.source_name)
337

    
338
        source_archives_obj = {
339
            'zip' : {
340
                'sha256': self._prepare_source_package_zip(root_dir_path)
341
            }
342
        }
343

    
344
        self.source_description = {
345
            '$schema':            schema,
346
            'source_name':        self.source_name,
347
            'source_copyright':   self.copyright_file_refs,
348
            'upstream_url':       index_obj['upstream_url'],
349
            'definitions':        item_refs,
350
            'source_archives':    source_archives_obj,
351
            'generated_by':       generated_by
352
        }
353

    
354
        if 'comment' in index_obj:
355
            self.source_description['comment'] = index_obj['comment']
356

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

    
365
    def write_package_files(self, dstpath: Path):
366
        """Write package files under 'dstpath' for distribution."""
367
        file_dir_path = (dstpath / 'file' / 'sha256').resolve()
368
        file_dir_path.mkdir(parents=True, exist_ok=True)
369

    
370
        for file_ref in self.files_by_path.values():
371
            if file_ref.include_in_distribution:
372
                file_path = file_dir_path / file_ref.contents_hash
373
                file_path.write_bytes(file_ref.contents)
374

    
375
        source_dir_path = (dstpath / 'source').resolve()
376
        source_dir_path.mkdir(parents=True, exist_ok=True)
377
        source_name = self.source_description["source_name"]
378

    
379
        with open(source_dir_path / f'{source_name}.json', 'wt') as output:
380
            json.dump(self.source_description, output)
381

    
382
        with open(source_dir_path / f'{source_name}.zip', 'wb') as output:
383
            output.write(self.source_zip_contents)
384

    
385
        for item_type, item_list in [
386
                ('resource', self.resource_list),
387
                ('mapping', self.mapping_list)
388
        ]:
389
            item_type_dir_path = (dstpath / item_type).resolve()
390

    
391
            for item_def in item_list:
392
                item_dir_path = item_type_dir_path / item_def['identifier']
393
                item_dir_path.mkdir(parents=True, exist_ok=True)
394

    
395
                version = '.'.join([str(n) for n in item_def['version']])
396
                with open(item_dir_path / version, 'wt') as output:
397
                    json.dump(item_def, output)
398

    
399
dir_type = click.Path(exists=True, file_okay=False, resolve_path=True)
400

    
401
@click.option('-s', '--srcdir', default='./', type=dir_type, show_default=True,
402
              help=_('source_directory_to_build_from'))
403
@click.option('-i', '--index-json', default='index.json', type=click.Path(),
404
              help=_('path_instead_of_index_json'))
405
@click.option('-d', '--dstdir', type=dir_type, required=True,
406
              help=_('built_package_files_destination'))
407
@click.version_option(version=_version.version, prog_name='Hydrilla builder',
408
                      message=_('%(prog)s_%(version)s_license'),
409
                      help=_('version_printing'))
410
def perform(srcdir, index_json, dstdir):
411
    """<this will be replaced by a localized docstring for Click to pick up>"""
412
    build = Build(Path(srcdir), Path(index_json))
413
    build.write_package_files(Path(dstdir))
414

    
415
perform.__doc__ = _('build_package_from_srcdir_to_dstdir')
416

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