Project

General

Profile

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

hydrilla-builder / src / hydrilla / builder / build.py @ 61f0aa75

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
import subprocess
34
from pathlib import Path, PurePosixPath
35
from hashlib import sha256
36
from sys import stderr
37
from contextlib import contextmanager
38
from tempfile import TemporaryDirectory, TemporaryFile
39
from typing import Optional, Iterable, Union
40

    
41
import jsonschema
42
import click
43

    
44
from .. import util
45
from . import _version
46
from . import local_apt
47
from .piggybacking import Piggybacked
48
from .common_errors import *
49

    
50
here = Path(__file__).resolve().parent
51

    
52
_ = util.translation(here / 'locales').gettext
53

    
54
index_validator = util.validator_for('package_source-2.schema.json')
55

    
56
schemas_root = 'https://hydrilla.koszko.org/schemas'
57

    
58
generated_by = {
59
    'name': 'hydrilla.builder',
60
    'version': _version.version
61
}
62

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

    
68
def generate_spdx_report(root: Path) -> bytes:
69
    """
70
    Use REUSE tool to generate an SPDX report for sources under 'root' and
71
    return the report's contents as 'bytes'.
72

    
73
    In case the directory tree under 'root' does not constitute a
74
    REUSE-compliant package, as exception is raised with linting report
75
    included in it.
76

    
77
    In case the reuse tool is not installed, an exception is also raised.
78
    """
79
    for command in [
80
            ['reuse', '--root', str(root), 'lint'],
81
            ['reuse', '--root', str(root), 'spdx']
82
    ]:
83
        try:
84
            cp = subprocess.run(command, capture_output=True, text=True)
85
        except FileNotFoundError:
86
            raise ReuseError(_('couldnt_execute_reuse_is_it_installed'))
87

    
88
        if cp.returncode != 0:
89
            msg = _('reuse_command_{}_failed').format(' '.join(command))
90
            raise ReuseError(msg, cp)
91

    
92
    return cp.stdout.encode()
93

    
94
class FileRef:
95
    """Represent reference to a file in the package."""
96
    def __init__(self, path: PurePosixPath, contents: bytes) -> None:
97
        """Initialize FileRef."""
98
        self.include_in_distribution   = False
99
        self.include_in_source_archive = True
100
        self.path                      = path
101
        self.contents                  = contents
102

    
103
        self.contents_hash = sha256(contents).digest().hex()
104

    
105
    def make_ref_dict(self) -> dict[str, str]:
106
        """
107
        Represent the file reference through a dict that can be included in JSON
108
        defintions.
109
        """
110
        return {
111
            'file':   str(self.path),
112
            'sha256': self.contents_hash
113
        }
114

    
115
@contextmanager
116
def piggybacked_system(piggyback_def: Optional[dict],
117
                       piggyback_files: Optional[Path]) \
118
                       -> Iterable[Piggybacked]:
119
    """
120
    Resolve resources from a foreign software packaging system. Optionally, use
121
    package files (.deb's, etc.) from a specified directory instead of resolving
122
    and downloading them.
123
    """
124
    if piggyback_def is None:
125
        yield Piggybacked()
126
    else:
127
        # apt is the only supported system right now
128
        assert piggyback_def['system'] == 'apt'
129

    
130
        with local_apt.piggybacked_system(piggyback_def, piggyback_files) \
131
             as piggybacked:
132
            yield piggybacked
133

    
134
class Build:
135
    """
136
    Build a Hydrilla package.
137
    """
138
    def __init__(self, srcdir: Path, index_json_path: Path,
139
                 piggyback_files: Optional[Path]=None):
140
        """
141
        Initialize a build. All files to be included in a distribution package
142
        are loaded into memory, all data gets validated and all necessary
143
        computations (e.g. preparing of hashes) are performed.
144
        """
145
        self.srcdir          = srcdir.resolve()
146
        self.piggyback_files = piggyback_files
147
        # TODO: the piggyback files we set are ignored for now; use them
148
        if piggyback_files is None:
149
            piggyback_default_path = \
150
                srcdir.parent / f'{srcdir.name}.foreign-packages'
151
            if piggyback_default_path.exists():
152
                self.piggyback_files = piggyback_default_path
153
        self.files_by_path   = {}
154
        self.resource_list   = []
155
        self.mapping_list    = []
156

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

    
160
        with open(index_json_path, 'rt') as index_file:
161
            index_json_text = index_file.read()
162

    
163
        index_obj = json.loads(util.strip_json_comments(index_json_text))
164

    
165
        index_desired_path = PurePosixPath('index.json')
166
        self.files_by_path[index_desired_path] = \
167
            FileRef(index_desired_path, index_json_text.encode())
168

    
169
        self._process_index_json(index_obj)
170

    
171
    def _process_file(self, filename: Union[str, PurePosixPath],
172
                      piggybacked: Piggybacked,
173
                      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 withing package directory.
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
        For each file an attempt is made to resolve it using 'piggybacked'
186
        object. If a file is found and pulled from foreign software packaging
187
        system this way, it gets automatically excluded from inclusion in
188
        Hydrilla source package's zipfile.
189

    
190
        Return file's reference object that can be included in JSON defintions
191
        of various kinds.
192
        """
193
        include_in_source_archive = True
194

    
195
        desired_path = PurePosixPath(filename)
196
        if '..' in desired_path.parts:
197
            msg = _('path_contains_double_dot_{}').format(filename)
198
            raise FileReferenceError(msg)
199

    
200
        path = piggybacked.resolve_file(desired_path)
201
        if path is None:
202
            path = (self.srcdir / desired_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
        else:
210
            include_in_source_archive = False
211

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

    
217
            file_ref = FileRef(desired_path, contents)
218
            self.files_by_path[desired_path] = file_ref
219

    
220
        if include_in_distribution:
221
            file_ref.include_in_distribution = True
222

    
223
        if not include_in_source_archive:
224
            file_ref.include_in_source_archive = False
225

    
226
        return file_ref.make_ref_dict()
227

    
228
    def _prepare_source_package_zip(self, source_name: str,
229
                                    piggybacked: Piggybacked) -> str:
230
        """
231
        Create and store in memory a .zip archive containing files needed to
232
        build this source package.
233

    
234
        'src_dir_name' shall not contain any slashes ('/').
235

    
236
        Return zipfile's sha256 sum's hexstring.
237
        """
238
        tf = TemporaryFile()
239
        source_dir_path      = PurePosixPath(source_name)
240
        piggybacked_dir_path = PurePosixPath(f'{source_name}.foreign-packages')
241

    
242
        with zipfile.ZipFile(tf, 'w') as zf:
243
            for file_ref in self.files_by_path.values():
244
                if file_ref.include_in_source_archive:
245
                    zf.writestr(str(source_dir_path / file_ref.path),
246
                                file_ref.contents)
247

    
248
            for desired_path, real_path in piggybacked.archive_files():
249
                zf.writestr(str(piggybacked_dir_path / desired_path),
250
                            real_path.read_bytes())
251

    
252
        tf.seek(0)
253
        self.source_zip_contents = tf.read()
254

    
255
        return sha256(self.source_zip_contents).digest().hex()
256

    
257
    def _process_item(self, item_def: dict, piggybacked: Piggybacked):
258
        """
259
        Process 'item_def' as definition of a resource/mapping and store in
260
        memory its processed form and files used by it.
261

    
262
        Return a minimal item reference suitable for using in source
263
        description.
264
        """
265
        copy_props = ['type', 'identifier', 'long_name', 'description']
266
        for prop in ('comment', 'uuid'):
267
            if prop in item_def:
268
                copy_props.append(prop)
269

    
270
        if item_def['type'] == 'resource':
271
            item_list = self.resource_list
272

    
273
            copy_props.append('revision')
274

    
275
            script_file_refs = [self._process_file(f['file'], piggybacked)
276
                                for f in item_def.get('scripts', [])]
277

    
278
            deps = [{'identifier': res_ref['identifier']}
279
                    for res_ref in item_def.get('dependencies', [])]
280

    
281
            new_item_obj = {
282
                'dependencies': [*piggybacked.package_must_depend, *deps],
283
                'scripts':      script_file_refs
284
            }
285
        else:
286
            item_list = self.mapping_list
287

    
288
            payloads = {}
289
            for pat, res_ref in item_def.get('payloads', {}).items():
290
                payloads[pat] = {'identifier': res_ref['identifier']}
291

    
292
            new_item_obj = {
293
                'payloads': payloads
294
            }
295

    
296
        new_item_obj.update([(p, item_def[p]) for p in copy_props])
297

    
298
        new_item_obj['version'] = util.normalize_version(item_def['version'])
299
        new_item_obj['$schema'] = f'{schemas_root}/api_{item_def["type"]}_description-1.schema.json'
300
        new_item_obj['source_copyright'] = self.copyright_file_refs
301
        new_item_obj['source_name'] = self.source_name
302
        new_item_obj['generated_by'] = generated_by
303

    
304
        item_list.append(new_item_obj)
305

    
306
        props_in_ref = ('type', 'identifier', 'version', 'long_name')
307
        return dict([(prop, new_item_obj[prop]) for prop in props_in_ref])
308

    
309
    def _process_index_json(self, index_obj: dict):
310
        """
311
        Process 'index_obj' as contents of source package's index.json and store
312
        in memory this source package's zipfile as well as package's individual
313
        files and computed definitions of the source package and items defined
314
        in it.
315
        """
316
        index_validator.validate(index_obj)
317
        match = re.match(r'.*-((([1-9][0-9]*|0)\.)+)schema\.json$',
318
                         index_obj['$schema'])
319
        self.source_schema_ver = \
320
            [int(n) for n in filter(None, match.group(1).split('.'))]
321

    
322
        out_schema = f'{schemas_root}/api_source_description-1.schema.json'
323

    
324
        self.source_name = index_obj['source_name']
325

    
326
        generate_spdx = index_obj.get('reuse_generate_spdx_report', False)
327
        if generate_spdx:
328
            contents  = generate_spdx_report(self.srcdir)
329
            spdx_path = PurePosixPath('report.spdx')
330
            spdx_ref  = FileRef(spdx_path, contents)
331

    
332
            spdx_ref.include_in_source_archive = False
333
            self.files_by_path[spdx_path] = spdx_ref
334

    
335
        piggyback_def = None
336
        if self.source_schema_ver >= [1, 1] and 'piggyback_on' in index_obj:
337
            piggyback_def = index_obj['piggyback_on']
338

    
339
        with piggybacked_system(piggyback_def, self.piggyback_files) \
340
             as piggybacked:
341
            copyright_to_process = [
342
                *(file_ref['file'] for file_ref in index_obj['copyright']),
343
                *piggybacked.package_license_files
344
            ]
345
            self.copyright_file_refs = [self._process_file(f, piggybacked)
346
                                        for f in copyright_to_process]
347

    
348
            if generate_spdx and not spdx_ref.include_in_distribution:
349
                raise FileReferenceError(_('report_spdx_not_in_copyright_list'))
350

    
351
            item_refs = [self._process_item(d, piggybacked)
352
                         for d in index_obj['definitions']]
353

    
354
            for file_ref in index_obj.get('additional_files', []):
355
                self._process_file(file_ref['file'], piggybacked,
356
                                   include_in_distribution=False)
357

    
358
            zipfile_sha256 = self._prepare_source_package_zip\
359
                (self.source_name, piggybacked)
360

    
361
            source_archives_obj = {'zip' : {'sha256': zipfile_sha256}}
362

    
363
        self.source_description = {
364
            '$schema':            out_schema,
365
            'source_name':        self.source_name,
366
            'source_copyright':   self.copyright_file_refs,
367
            'upstream_url':       index_obj['upstream_url'],
368
            'definitions':        item_refs,
369
            'source_archives':    source_archives_obj,
370
            'generated_by':       generated_by
371
        }
372

    
373
        if 'comment' in index_obj:
374
            self.source_description['comment'] = index_obj['comment']
375

    
376
    def write_source_package_zip(self, dstpath: Path):
377
        """
378
        Create a .zip archive containing files needed to build this source
379
        package and write it at 'dstpath'.
380
        """
381
        with open(dstpath, 'wb') as output:
382
            output.write(self.source_zip_contents)
383

    
384
    def write_package_files(self, dstpath: Path):
385
        """Write package files under 'dstpath' for distribution."""
386
        file_dir_path = (dstpath / 'file' / 'sha256').resolve()
387
        file_dir_path.mkdir(parents=True, exist_ok=True)
388

    
389
        for file_ref in self.files_by_path.values():
390
            if file_ref.include_in_distribution:
391
                file_path = file_dir_path / file_ref.contents_hash
392
                file_path.write_bytes(file_ref.contents)
393

    
394
        source_dir_path = (dstpath / 'source').resolve()
395
        source_dir_path.mkdir(parents=True, exist_ok=True)
396
        source_name = self.source_description["source_name"]
397

    
398
        with open(source_dir_path / f'{source_name}.json', 'wt') as output:
399
            json.dump(self.source_description, output)
400

    
401
        with open(source_dir_path / f'{source_name}.zip', 'wb') as output:
402
            output.write(self.source_zip_contents)
403

    
404
        for item_type, item_list in [
405
                ('resource', self.resource_list),
406
                ('mapping', self.mapping_list)
407
        ]:
408
            item_type_dir_path = (dstpath / item_type).resolve()
409

    
410
            for item_def in item_list:
411
                item_dir_path = item_type_dir_path / item_def['identifier']
412
                item_dir_path.mkdir(parents=True, exist_ok=True)
413

    
414
                version = '.'.join([str(n) for n in item_def['version']])
415
                with open(item_dir_path / version, 'wt') as output:
416
                    json.dump(item_def, output)
417

    
418
dir_type = click.Path(exists=True, file_okay=False, resolve_path=True)
419

    
420
@click.command(help=_('build_package_from_srcdir_to_dstdir'))
421
@click.option('-s', '--srcdir', default='./', type=dir_type, show_default=True,
422
              help=_('source_directory_to_build_from'))
423
@click.option('-i', '--index-json', default='index.json', type=click.Path(),
424
              help=_('path_instead_of_index_json'))
425
@click.option('-p', '--piggyback-files', type=click.Path(),
426
              help=_('path_instead_for_piggyback_files'))
427
@click.option('-d', '--dstdir', type=dir_type, required=True,
428
              help=_('built_package_files_destination'))
429
@click.version_option(version=_version.version, prog_name='Hydrilla builder',
430
                      message=_('%(prog)s_%(version)s_license'),
431
                      help=_('version_printing'))
432
def perform(srcdir, index_json, piggyback_files, dstdir):
433
    """
434
    Execute Hydrilla builder to turn source package into a distributable one.
435

    
436
    This command is meant to be the entry point of hydrilla-builder command
437
    exported by this package.
438
    """
439
    build = Build(Path(srcdir), Path(index_json),
440
                  piggyback_files and Path(piggyback_files))
441
    build.write_package_files(Path(dstdir))
(3-3/6)