Project

General

Profile

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

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

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
generated_by = {
47
    'name': 'hydrilla.builder',
48
    'version': _version.version
49
}
50

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

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

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

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

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

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

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

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

    
102
    'root' shall be an instance of pathlib.Path.
103

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

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

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

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

    
125
    return mocked_output.get_bytes()
126

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

    
136
        self.contents_hash = sha256(contents).digest().hex()
137

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

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

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

    
166
        if not index_json_path.is_absolute():
167
            self.index_json_path = (self.srcdir / self.index_json_path)
168

    
169
        self.index_json_path = self.index_json_path.resolve()
170

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

    
174
        index_obj = json.loads(util.strip_json_comments(index_json_text))
175

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

    
179
        self._process_index_json(index_obj)
180

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

    
187
        'filename' shall represent a relative path using '/' as a separator.
188

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

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

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

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

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

    
213
            file_ref = FileRef(path, contents)
214
            self.files_by_path[path] = file_ref
215

    
216
        if include_in_distribution:
217
            file_ref.include_in_distribution = True
218

    
219
        return file_ref.make_ref_dict(filename)
220

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

    
226
        'root_dir_name' shall not contain any slashes ('/').
227

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

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

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

    
242
        self.source_zip_contents = fb.get_bytes()
243

    
244
        return sha256(self.source_zip_contents).digest().hex()
245

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

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

    
258
        if item_def['type'] == 'resource':
259
            item_list = self.resource_list
260

    
261
            copy_props.append('revision')
262

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

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

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

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

    
280
            new_item_obj = {
281
                'payloads': payloads
282
            }
283

    
284
        new_item_obj.update([(p, item_def[p]) for p in copy_props])
285

    
286
        new_item_obj['version'] = util.normalize_version(item_def['version'])
287
        new_item_obj['api_schema_version'] = [1]
288
        new_item_obj['source_copyright'] = self.copyright_file_refs
289
        new_item_obj['source_name'] = self.source_name
290
        new_item_obj['generated_by'] = generated_by
291

    
292
        item_list.append(new_item_obj)
293

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

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

    
306
        self.source_name = index_obj['source_name']
307

    
308
        generate_spdx = index_obj.get('reuse_generate_spdx_report', False)
309
        if generate_spdx:
310
            contents  = generate_spdx_report(self.srcdir)
311
            spdx_path = (self.srcdir / 'report.spdx').resolve()
312
            spdx_ref  = FileRef(spdx_path, contents)
313

    
314
            spdx_ref.include_in_zipfile = False
315
            self.files_by_path[spdx_path] = spdx_ref
316

    
317
        self.copyright_file_refs = \
318
            [self._process_file(f['file']) for f in index_obj['copyright']]
319

    
320
        if generate_spdx and not spdx_ref.include_in_distribution:
321
            raise FileReferenceError(_('report_spdx_not_in_copyright_list'))
322

    
323
        item_refs = [self._process_item(d) for d in index_obj['definitions']]
324

    
325
        for file_ref in index_obj.get('additional_files', []):
326
            self._process_file(file_ref['file'], include_in_distribution=False)
327

    
328
        root_dir_path = Path(self.source_name)
329

    
330
        source_archives_obj = {
331
            'zip' : {
332
                'sha256': self._prepare_source_package_zip(root_dir_path)
333
            }
334
        }
335

    
336
        self.source_description = {
337
            'api_schema_version': [1],
338
            'source_name':        self.source_name,
339
            'source_copyright':   self.copyright_file_refs,
340
            'upstream_url':       index_obj['upstream_url'],
341
            'definitions':        item_refs,
342
            'source_archives':    source_archives_obj,
343
            'generated_by':       generated_by
344
        }
345

    
346
        if 'comment' in index_obj:
347
            self.source_description['comment'] = index_obj['comment']
348

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

    
357
    def write_package_files(self, dstpath: Path):
358
        """Write package files under 'dstpath' for distribution."""
359
        file_dir_path = (dstpath / 'file' / 'sha256').resolve()
360
        file_dir_path.mkdir(parents=True, exist_ok=True)
361

    
362
        for file_ref in self.files_by_path.values():
363
            if file_ref.include_in_distribution:
364
                file_path = file_dir_path / file_ref.contents_hash
365
                file_path.write_bytes(file_ref.contents)
366

    
367
        source_dir_path = (dstpath / 'source').resolve()
368
        source_dir_path.mkdir(parents=True, exist_ok=True)
369
        source_name = self.source_description["source_name"]
370

    
371
        with open(source_dir_path / f'{source_name}.json', 'wt') as output:
372
            json.dump(self.source_description, output)
373

    
374
        with open(source_dir_path / f'{source_name}.zip', 'wb') as output:
375
            output.write(self.source_zip_contents)
376

    
377
        for item_type, item_list in [
378
                ('resource', self.resource_list),
379
                ('mapping', self.mapping_list)
380
        ]:
381
            item_type_dir_path = (dstpath / item_type).resolve()
382

    
383
            for item_def in item_list:
384
                item_dir_path = item_type_dir_path / item_def['identifier']
385
                item_dir_path.mkdir(parents=True, exist_ok=True)
386

    
387
                version = '.'.join([str(n) for n in item_def['version']])
388
                with open(item_dir_path / version, 'wt') as output:
389
                    json.dump(item_def, output)
390

    
391
dir_type = click.Path(exists=True, file_okay=False, resolve_path=True)
392

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

    
404
perform.__doc__ = _('build_package_from_srcdir_to_dstdir')
405

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