diff options
author | Wojtek Kosior <koszko@koszko.org> | 2022-04-25 19:46:54 +0200 |
---|---|---|
committer | Wojtek Kosior <koszko@koszko.org> | 2022-04-25 19:46:54 +0200 |
commit | 6bc04f8097e44e55cbf543d811ccd90671faedbc (patch) | |
tree | bf8b43dfbb8a1f3e7ffe1439a304aa33bd32bf24 /src | |
parent | 68d2102c7f791b8607638167ed4aadb5d71dea1b (diff) | |
download | hydrilla-builder-6bc04f8097e44e55cbf543d811ccd90671faedbc.tar.gz hydrilla-builder-6bc04f8097e44e55cbf543d811ccd90671faedbc.zip |
New upstream version 1.0upstream/1.0
Diffstat (limited to 'src')
27 files changed, 1305 insertions, 0 deletions
diff --git a/src/hydrilla.builder.egg-info/PKG-INFO b/src/hydrilla.builder.egg-info/PKG-INFO new file mode 100644 index 0000000..7724a25 --- /dev/null +++ b/src/hydrilla.builder.egg-info/PKG-INFO @@ -0,0 +1,89 @@ +Metadata-Version: 2.1 +Name: hydrilla.builder +Version: 1.0 +Summary: Hydrilla package builder +Home-page: https://git.koszko.org/hydrilla-builder +Author: Wojtek Kosior +Author-email: koszko@koszko.org +License: AGPL-3.0-or-later +Project-URL: Bug Tracker, https://hydrillabugs.koszko.org/projects/hydrilla +Description: # Hydrilla builder + + This is the repository of the builder part of [Hydrilla](https://hydrillabugs.koszko.org/projects/hydrilla/wiki). You can find the repository of its server part [here](https://git.koszko.org/pydrilla/). + + Hydrilla builder is a tool to create Haketilo packages in serveable form. The information below is meant to help hack on the codebase. If you're instead looking for some noob-friendly documentation, see the [user manual](https://hydrillabugs.koszko.org/projects/hydrilla/wiki/User_manual). + + ## Dependencies + + ### Runtime + + * Python3 (>= 3.7) + * click + * jsonschema (>= 3.0) + * reuse [optional] + + ### Build + + * setuptools + * wheel + * setuptools_scm + * babel + + ### Test + + * pytest + * reuse + + ## Building & testing + + Build and test processed are analogous to those described in the [README of Hydrilla server part](https://git.koszko.org/pydrilla/about). + + ## Running + + This package provides a hydrilla-builder command. You can use it to build the supplied example with something along the lines of: + + ``` + mkdir /tmp/bananowarzez/ + hydrilla-builder -s src/test/source-package-example/ -d /tmp/bananowarzez/ + # Now, list the serveable package files we just produced. + find /tmp/bananowarzez/ + ``` + + You might as well like to run from sources, without installation: + mkdir /tmp/bananowarzez/ + ./setup.py compile_catalog # generate the necessary .po files + PYTHONPATH=src python3 -m hydrilla.builder -s src/test/source-package-example/ \ + -d /tmp/bananowarzez/ + ``` + + You can also consult the included manpage (`man` tool required): + ``` shell + man ./doc/man/man1/hydrilla-builder.1 + ``` + + ## Copying + + Hydrilla is Copyright (C) 2021-2022 Wojtek Kosior and contributors, entirely available under the GNU Affero General Public License version 3 or later. Some files might also give you broader permissions, see comments inside them. + + *I, Wojtek Kosior, thereby promise not to sue for violation of this project's license. Although I request that you do not make use this code in a proprietary program, I am not going to enforce this in court.* + + ## Contributing + + Please visit our Redmine instance at https://hydrillabugs.koszko.org. + + You can also write an email to koszko@koszko.org. + +Keywords: hydrilla,haketilo +Platform: UNKNOWN +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: Environment :: Console +Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+) +Classifier: Natural Language :: English +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Topic :: Software Development :: Build Tools +Requires-Python: >=3.7 +Description-Content-Type: text/markdown +Provides-Extra: setup +Provides-Extra: test diff --git a/src/hydrilla.builder.egg-info/SOURCES.txt b/src/hydrilla.builder.egg-info/SOURCES.txt new file mode 100644 index 0000000..de24145 --- /dev/null +++ b/src/hydrilla.builder.egg-info/SOURCES.txt @@ -0,0 +1,47 @@ +MANIFEST.in +README.md +README.md.license +pyproject.toml +setup.cfg +setup.py +.reuse/dep5 +LICENSES/0BSD.txt +LICENSES/AGPL-3.0-or-later.txt +LICENSES/CC0-1.0.txt +doc/man/man1/hydrilla-builder.1 +src/hydrilla/__init__.py +src/hydrilla.builder.egg-info/PKG-INFO +src/hydrilla.builder.egg-info/SOURCES.txt +src/hydrilla.builder.egg-info/dependency_links.txt +src/hydrilla.builder.egg-info/entry_points.txt +src/hydrilla.builder.egg-info/not-zip-safe +src/hydrilla.builder.egg-info/requires.txt +src/hydrilla.builder.egg-info/top_level.txt +src/hydrilla/builder/__init__.py +src/hydrilla/builder/__main__.py +src/hydrilla/builder/_version.py +src/hydrilla/builder/build.py +src/hydrilla/builder/locales/en_US/LC_MESSAGES/hydrilla-messages.po +src/hydrilla/schemas/api_mapping_description-1.0.1.schema.json +src/hydrilla/schemas/api_mapping_description-1.0.1.schema.json.license +src/hydrilla/schemas/api_query_result-1.0.1.schema.json +src/hydrilla/schemas/api_query_result-1.0.1.schema.json.license +src/hydrilla/schemas/api_resource_description-1.0.1.schema.json +src/hydrilla/schemas/api_resource_description-1.0.1.schema.json.license +src/hydrilla/schemas/api_source_description-1.0.1.schema.json +src/hydrilla/schemas/api_source_description-1.0.1.schema.json.license +src/hydrilla/schemas/common_definitions-1.0.1.schema.json +src/hydrilla/schemas/common_definitions-1.0.1.schema.json.license +src/hydrilla/schemas/package_source-1.0.1.schema.json +src/hydrilla/schemas/package_source-1.0.1.schema.json.license +src/hydrilla/util/__init__.py +src/hydrilla/util/_util.py +tests/test_hydrilla_builder.py +tests/source-package-example/README.txt +tests/source-package-example/README.txt.license +tests/source-package-example/bye.js +tests/source-package-example/hello.js +tests/source-package-example/index.json +tests/source-package-example/message.js +tests/source-package-example/.reuse/dep5 +tests/source-package-example/LICENSES/CC0-1.0.txt
\ No newline at end of file diff --git a/src/hydrilla.builder.egg-info/dependency_links.txt b/src/hydrilla.builder.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/hydrilla.builder.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/hydrilla.builder.egg-info/entry_points.txt b/src/hydrilla.builder.egg-info/entry_points.txt new file mode 100644 index 0000000..fe46978 --- /dev/null +++ b/src/hydrilla.builder.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +hydrilla-builder = hydrilla.builder.build:perform + diff --git a/src/hydrilla.builder.egg-info/not-zip-safe b/src/hydrilla.builder.egg-info/not-zip-safe new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/hydrilla.builder.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/src/hydrilla.builder.egg-info/requires.txt b/src/hydrilla.builder.egg-info/requires.txt new file mode 100644 index 0000000..4a40137 --- /dev/null +++ b/src/hydrilla.builder.egg-info/requires.txt @@ -0,0 +1,8 @@ +click +jsonschema>=3.0 + +[setup] +setuptools_scm + +[test] +pytest diff --git a/src/hydrilla.builder.egg-info/top_level.txt b/src/hydrilla.builder.egg-info/top_level.txt new file mode 100644 index 0000000..85c40e2 --- /dev/null +++ b/src/hydrilla.builder.egg-info/top_level.txt @@ -0,0 +1 @@ +hydrilla diff --git a/src/hydrilla/__init__.py b/src/hydrilla/__init__.py new file mode 100644 index 0000000..6aeb276 --- /dev/null +++ b/src/hydrilla/__init__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: 0BSD + +# Copyright (C) 2013-2020, PyPA + +# https://packaging.python.org/en/latest/guides/packaging-namespace-packages/#pkgutil-style-namespace-packages + +__path__ = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/src/hydrilla/builder/__init__.py b/src/hydrilla/builder/__init__.py new file mode 100644 index 0000000..73dc579 --- /dev/null +++ b/src/hydrilla/builder/__init__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: CC0-1.0 + +# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> +# +# Available under the terms of Creative Commons Zero v1.0 Universal. + +from .build import Build diff --git a/src/hydrilla/builder/__main__.py b/src/hydrilla/builder/__main__.py new file mode 100644 index 0000000..87dc9e2 --- /dev/null +++ b/src/hydrilla/builder/__main__.py @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: CC0-1.0 + +# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> +# +# Available under the terms of Creative Commons Zero v1.0 Universal. + +from . import build + +build.perform() diff --git a/src/hydrilla/builder/_version.py b/src/hydrilla/builder/_version.py new file mode 100644 index 0000000..d953eef --- /dev/null +++ b/src/hydrilla/builder/_version.py @@ -0,0 +1,5 @@ +# coding: utf-8 +# file generated by setuptools_scm +# don't change, don't track in version control +version = '1.0' +version_tuple = (1, 0) diff --git a/src/hydrilla/builder/build.py b/src/hydrilla/builder/build.py new file mode 100644 index 0000000..8eec4a4 --- /dev/null +++ b/src/hydrilla/builder/build.py @@ -0,0 +1,417 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Building Hydrilla packages. +# +# This file is part of Hydrilla +# +# Copyright (C) 2022 Wojtek Kosior +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# +# I, Wojtek Kosior, thereby promise not to sue for violation of this +# file's license. Although I request that you do not make use this code +# in a proprietary program, I am not going to enforce this in court. + +# Enable using with Python 3.7. +from __future__ import annotations + +import json +import re +import zipfile +from pathlib import Path +from hashlib import sha256 +from sys import stderr + +import jsonschema +import click + +from .. import util +from . import _version + +here = Path(__file__).resolve().parent + +_ = util.translation(here / 'locales').gettext + +index_validator = util.validator_for('package_source-1.0.1.schema.json') + +schemas_root = 'https://hydrilla.koszko.org/schemas' + +generated_by = { + 'name': 'hydrilla.builder', + 'version': _version.version +} + +class FileReferenceError(Exception): + """ + Exception used to report various problems concerning files referenced from + source package's index.json. + """ + +class ReuseError(Exception): + """ + Exception used to report various problems when calling the REUSE tool. + """ + +class FileBuffer: + """ + Implement a file-like object that buffers data written to it. + """ + def __init__(self): + """ + Initialize FileBuffer. + """ + self.chunks = [] + + def write(self, b): + """ + Buffer 'b', return number of bytes buffered. + + 'b' is expected to be an instance of 'bytes' or 'str', in which case it + gets encoded as UTF-8. + """ + if type(b) is str: + b = b.encode() + self.chunks.append(b) + return len(b) + + def flush(self): + """ + A no-op mock of file-like object's flush() method. + """ + pass + + def get_bytes(self): + """ + Return all data written so far concatenated into a single 'bytes' + object. + """ + return b''.join(self.chunks) + +def generate_spdx_report(root): + """ + Use REUSE tool to generate an SPDX report for sources under 'root' and + return the report's contents as 'bytes'. + + 'root' shall be an instance of pathlib.Path. + + In case the directory tree under 'root' does not constitute a + REUSE-compliant package, linting report is printed to standard output and + an exception is raised. + + In case the reuse package is not installed, an exception is also raised. + """ + try: + from reuse._main import main as reuse_main + except ModuleNotFoundError: + raise ReuseError(_('couldnt_import_reuse_is_it_installed')) + + mocked_output = FileBuffer() + if reuse_main(args=['--root', str(root), 'lint'], out=mocked_output) != 0: + stderr.write(mocked_output.get_bytes().decode()) + raise ReuseError(_('spdx_report_from_reuse_incompliant')) + + mocked_output = FileBuffer() + if reuse_main(args=['--root', str(root), 'spdx'], out=mocked_output) != 0: + stderr.write(mocked_output.get_bytes().decode()) + raise ReuseError("Couldn't generate an SPDX report for package.") + + return mocked_output.get_bytes() + +class FileRef: + """Represent reference to a file in the package.""" + def __init__(self, path: Path, contents: bytes): + """Initialize FileRef.""" + self.include_in_distribution = False + self.include_in_zipfile = True + self.path = path + self.contents = contents + + self.contents_hash = sha256(contents).digest().hex() + + def make_ref_dict(self, filename: str): + """ + Represent the file reference through a dict that can be included in JSON + defintions. + """ + return { + 'file': filename, + 'sha256': self.contents_hash + } + +class Build: + """ + Build a Hydrilla package. + """ + def __init__(self, srcdir, index_json_path): + """ + Initialize a build. All files to be included in a distribution package + are loaded into memory, all data gets validated and all necessary + computations (e.g. preparing of hashes) are performed. + + 'srcdir' and 'index_json' are expected to be pathlib.Path objects. + """ + self.srcdir = srcdir.resolve() + self.index_json_path = index_json_path + self.files_by_path = {} + self.resource_list = [] + self.mapping_list = [] + + if not index_json_path.is_absolute(): + self.index_json_path = (self.srcdir / self.index_json_path) + + self.index_json_path = self.index_json_path.resolve() + + with open(self.index_json_path, 'rt') as index_file: + index_json_text = index_file.read() + + index_obj = json.loads(util.strip_json_comments(index_json_text)) + + self.files_by_path[self.srcdir / 'index.json'] = \ + FileRef(self.srcdir / 'index.json', index_json_text.encode()) + + self._process_index_json(index_obj) + + def _process_file(self, filename: str, include_in_distribution: bool=True): + """ + Resolve 'filename' relative to srcdir, load it to memory (if not loaded + before), compute its hash and store its information in + 'self.files_by_path'. + + 'filename' shall represent a relative path using '/' as a separator. + + if 'include_in_distribution' is True it shall cause the file to not only + be included in the source package's zipfile, but also written as one of + built package's files. + + Return file's reference object that can be included in JSON defintions + of various kinds. + """ + path = self.srcdir + for segment in filename.split('/'): + path /= segment + + path = path.resolve() + if not path.is_relative_to(self.srcdir): + raise FileReferenceError(_('loading_{}_outside_package_dir') + .format(filename)) + + if str(path.relative_to(self.srcdir)) == 'index.json': + raise FileReferenceError(_('loading_reserved_index_json')) + + file_ref = self.files_by_path.get(path) + if file_ref is None: + with open(path, 'rb') as file_handle: + contents = file_handle.read() + + file_ref = FileRef(path, contents) + self.files_by_path[path] = file_ref + + if include_in_distribution: + file_ref.include_in_distribution = True + + return file_ref.make_ref_dict(filename) + + def _prepare_source_package_zip(self, root_dir_name: str): + """ + Create and store in memory a .zip archive containing files needed to + build this source package. + + 'root_dir_name' shall not contain any slashes ('/'). + + Return zipfile's sha256 sum's hexstring. + """ + fb = FileBuffer() + root_dir_path = Path(root_dir_name) + + def zippath(file_path): + file_path = root_dir_path / file_path.relative_to(self.srcdir) + return file_path.as_posix() + + with zipfile.ZipFile(fb, 'w') as xpi: + for file_ref in self.files_by_path.values(): + if file_ref.include_in_zipfile: + xpi.writestr(zippath(file_ref.path), file_ref.contents) + + self.source_zip_contents = fb.get_bytes() + + return sha256(self.source_zip_contents).digest().hex() + + def _process_item(self, item_def: dict): + """ + Process 'item_def' as definition of a resource/mapping and store in + memory its processed form and files used by it. + + Return a minimal item reference suitable for using in source + description. + """ + copy_props = ['type', 'identifier', 'long_name', 'description'] + for prop in ('comment', 'uuid'): + if prop in item_def: + copy_props.append(prop) + + if item_def['type'] == 'resource': + item_list = self.resource_list + + copy_props.append('revision') + + script_file_refs = [self._process_file(f['file']) + for f in item_def.get('scripts', [])] + + deps = [{'identifier': res_ref['identifier']} + for res_ref in item_def.get('dependencies', [])] + + new_item_obj = { + 'dependencies': deps, + 'scripts': script_file_refs + } + else: + item_list = self.mapping_list + + payloads = {} + for pat, res_ref in item_def.get('payloads', {}).items(): + payloads[pat] = {'identifier': res_ref['identifier']} + + new_item_obj = { + 'payloads': payloads + } + + new_item_obj.update([(p, item_def[p]) for p in copy_props]) + + new_item_obj['version'] = util.normalize_version(item_def['version']) + new_item_obj['$schema'] = f'{schemas_root}/api_{item_def["type"]}_description-1.schema.json' + new_item_obj['source_copyright'] = self.copyright_file_refs + new_item_obj['source_name'] = self.source_name + new_item_obj['generated_by'] = generated_by + + item_list.append(new_item_obj) + + props_in_ref = ('type', 'identifier', 'version', 'long_name') + return dict([(prop, new_item_obj[prop]) for prop in props_in_ref]) + + def _process_index_json(self, index_obj: dict): + """ + Process 'index_obj' as contents of source package's index.json and store + in memory this source package's zipfile as well as package's individual + files and computed definitions of the source package and items defined + in it. + """ + index_validator.validate(index_obj) + + schema = f'{schemas_root}/api_source_description-1.schema.json' + + self.source_name = index_obj['source_name'] + + generate_spdx = index_obj.get('reuse_generate_spdx_report', False) + if generate_spdx: + contents = generate_spdx_report(self.srcdir) + spdx_path = (self.srcdir / 'report.spdx').resolve() + spdx_ref = FileRef(spdx_path, contents) + + spdx_ref.include_in_zipfile = False + self.files_by_path[spdx_path] = spdx_ref + + self.copyright_file_refs = \ + [self._process_file(f['file']) for f in index_obj['copyright']] + + if generate_spdx and not spdx_ref.include_in_distribution: + raise FileReferenceError(_('report_spdx_not_in_copyright_list')) + + item_refs = [self._process_item(d) for d in index_obj['definitions']] + + for file_ref in index_obj.get('additional_files', []): + self._process_file(file_ref['file'], include_in_distribution=False) + + root_dir_path = Path(self.source_name) + + source_archives_obj = { + 'zip' : { + 'sha256': self._prepare_source_package_zip(root_dir_path) + } + } + + self.source_description = { + '$schema': schema, + 'source_name': self.source_name, + 'source_copyright': self.copyright_file_refs, + 'upstream_url': index_obj['upstream_url'], + 'definitions': item_refs, + 'source_archives': source_archives_obj, + 'generated_by': generated_by + } + + if 'comment' in index_obj: + self.source_description['comment'] = index_obj['comment'] + + def write_source_package_zip(self, dstpath: Path): + """ + Create a .zip archive containing files needed to build this source + package and write it at 'dstpath'. + """ + with open(dstpath, 'wb') as output: + output.write(self.source_zip_contents) + + def write_package_files(self, dstpath: Path): + """Write package files under 'dstpath' for distribution.""" + file_dir_path = (dstpath / 'file' / 'sha256').resolve() + file_dir_path.mkdir(parents=True, exist_ok=True) + + for file_ref in self.files_by_path.values(): + if file_ref.include_in_distribution: + file_path = file_dir_path / file_ref.contents_hash + file_path.write_bytes(file_ref.contents) + + source_dir_path = (dstpath / 'source').resolve() + source_dir_path.mkdir(parents=True, exist_ok=True) + source_name = self.source_description["source_name"] + + with open(source_dir_path / f'{source_name}.json', 'wt') as output: + json.dump(self.source_description, output) + + with open(source_dir_path / f'{source_name}.zip', 'wb') as output: + output.write(self.source_zip_contents) + + for item_type, item_list in [ + ('resource', self.resource_list), + ('mapping', self.mapping_list) + ]: + item_type_dir_path = (dstpath / item_type).resolve() + + for item_def in item_list: + item_dir_path = item_type_dir_path / item_def['identifier'] + item_dir_path.mkdir(parents=True, exist_ok=True) + + version = '.'.join([str(n) for n in item_def['version']]) + with open(item_dir_path / version, 'wt') as output: + json.dump(item_def, output) + +dir_type = click.Path(exists=True, file_okay=False, resolve_path=True) + +@click.option('-s', '--srcdir', default='./', type=dir_type, show_default=True, + help=_('source_directory_to_build_from')) +@click.option('-i', '--index-json', default='index.json', type=click.Path(), + help=_('path_instead_of_index_json')) +@click.option('-d', '--dstdir', type=dir_type, required=True, + help=_('built_package_files_destination')) +@click.version_option(version=_version.version, prog_name='Hydrilla builder', + message=_('%(prog)s_%(version)s_license'), + help=_('version_printing')) +def perform(srcdir, index_json, dstdir): + """<this will be replaced by a localized docstring for Click to pick up>""" + build = Build(Path(srcdir), Path(index_json)) + build.write_package_files(Path(dstdir)) + +perform.__doc__ = _('build_package_from_srcdir_to_dstdir') + +perform = click.command()(perform) diff --git a/src/hydrilla/builder/locales/en_US/LC_MESSAGES/hydrilla-messages.po b/src/hydrilla/builder/locales/en_US/LC_MESSAGES/hydrilla-messages.po new file mode 100644 index 0000000..e3ab525 --- /dev/null +++ b/src/hydrilla/builder/locales/en_US/LC_MESSAGES/hydrilla-messages.po @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: CC0-1.0 +# +# English (United States) translations for hydrilla.builder. +# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> +# Available under the terms of Creative Commons Zero v1.0 Universal. +msgid "" +msgstr "" +"Project-Id-Version: hydrilla.builder 0.1.dev16+g4e46d7f.d20220211\n" +"Report-Msgid-Bugs-To: koszko@koszko.org\n" +"POT-Creation-Date: 2022-04-19 13:51+0200\n" +"PO-Revision-Date: 2022-02-12 00:00+0000\n" +"Last-Translator: Wojtek Kosior <koszko@koszko.org>\n" +"Language: en_US\n" +"Language-Team: en_US <koszko@koszko.org>\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.8.0\n" + +#: src/hydrilla/builder/build.py:118 +msgid "couldnt_import_reuse_is_it_installed" +msgstr "" +"Could not import 'reuse'. Is the tool installed and visible to this " +"Python instance?" + +#: src/hydrilla/builder/build.py:123 +msgid "spdx_report_from_reuse_incompliant" +msgstr "Attempt to generate an SPDX report for a REUSE-incompliant package." + +#: src/hydrilla/builder/build.py:207 +msgid "loading_{}_outside_package_dir" +msgstr "Attempt to load '{}' which lies outside package source directory." + +#: src/hydrilla/builder/build.py:211 +msgid "loading_reserved_index_json" +msgstr "Attempt to load 'index.json' which is a reserved filename." + +#: src/hydrilla/builder/build.py:329 +msgid "report_spdx_not_in_copyright_list" +msgstr "" +"Told to generate 'report.spdx' but 'report.spdx' is not listed among " +"copyright files. Refusing to proceed." + +#: src/hydrilla/builder/build.py:402 +msgid "source_directory_to_build_from" +msgstr "Source directory to build from." + +#: src/hydrilla/builder/build.py:404 +msgid "path_instead_of_index_json" +msgstr "" +"Path to file to be processed instead of index.json (if not absolute, " +"resolved relative to srcdir)." + +#: src/hydrilla/builder/build.py:406 +msgid "built_package_files_destination" +msgstr "Destination directory to write built package files to." + +#: src/hydrilla/builder/build.py:408 +#, python-format +msgid "%(prog)s_%(version)s_license" +msgstr "" +"%(prog)s %(version)s\n" +"Copyright (C) 2021,2022 Wojtek Kosior and contributors.\n" +"License GPLv3+: GNU AGPL version 3 or later " +"<https://gnu.org/licenses/gpl.html>\n" +"This is free software: you are free to change and redistribute it.\n" +"There is NO WARRANTY, to the extent permitted by law." + +#: src/hydrilla/builder/build.py:409 +msgid "version_printing" +msgstr "Print version information and exit." + +#: src/hydrilla/builder/build.py:415 +msgid "build_package_from_srcdir_to_dstdir" +msgstr "" +"Build Hydrilla package from `scrdir` and write the resulting files under " +"`dstdir`." + +#: src/hydrilla/util/_util.py:79 +msgid "bad_comment" +msgstr "bad comment" + diff --git a/src/hydrilla/schemas/api_mapping_description-1.0.1.schema.json b/src/hydrilla/schemas/api_mapping_description-1.0.1.schema.json new file mode 100644 index 0000000..880a5c4 --- /dev/null +++ b/src/hydrilla/schemas/api_mapping_description-1.0.1.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://hydrilla.koszko.org/schemas/api_mapping_description-1.0.1.schema.json", + "title": "Mapping description", + "description": "Definition of a Hydrilla mapping, as served through HTTP API", + "allOf": [{ + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/mapping_definition_base" + }, { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/item_definition" + }, { + "type": "object", + "required": ["$schema"], + "properties": { + "$schema": { + "description": "Mark this instance as conforming to mapping description schema 1.x", + "type": "string", + "pattern": "^https://hydrilla\\.koszko\\.org/schemas/api_mapping_description-1\\.(([1-9][0-9]*|0)\\.)*schema\\.json$" + } + } + }] +} diff --git a/src/hydrilla/schemas/api_mapping_description-1.0.1.schema.json.license b/src/hydrilla/schemas/api_mapping_description-1.0.1.schema.json.license new file mode 100644 index 0000000..f41d511 --- /dev/null +++ b/src/hydrilla/schemas/api_mapping_description-1.0.1.schema.json.license @@ -0,0 +1,5 @@ +SPDX-License-Identifier: CC0-1.0 + +Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> + +Available under the terms of Creative Commons Zero v1.0 Universal. diff --git a/src/hydrilla/schemas/api_query_result-1.0.1.schema.json b/src/hydrilla/schemas/api_query_result-1.0.1.schema.json new file mode 100644 index 0000000..89c5428 --- /dev/null +++ b/src/hydrilla/schemas/api_query_result-1.0.1.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://hydrilla.koszko.org/schemas/api_query_result-1.0.1.schema.json", + "title": "Query result", + "description": "Object with a list of references to mappings that contain payloads for requested URL", + "type": "object", + "required": ["$schema", "mappings"], + "properties": { + "$schema": { + "description": "Mark this instance as conforming to query result schema 1.x", + "type": "string", + "pattern": "^https://hydrilla\\.koszko\\.org/schemas/api_query_result-1\\.(([1-9][0-9]*|0)\\.)*schema\\.json$" + }, + "mappings": { + "description": "References to mappings using at least one pattern that matches the requested URL", + "type": "array", + "items": { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/item_ref" + } + }, + "generated_by": { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/generated_by" + } + } +} diff --git a/src/hydrilla/schemas/api_query_result-1.0.1.schema.json.license b/src/hydrilla/schemas/api_query_result-1.0.1.schema.json.license new file mode 100644 index 0000000..f41d511 --- /dev/null +++ b/src/hydrilla/schemas/api_query_result-1.0.1.schema.json.license @@ -0,0 +1,5 @@ +SPDX-License-Identifier: CC0-1.0 + +Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> + +Available under the terms of Creative Commons Zero v1.0 Universal. diff --git a/src/hydrilla/schemas/api_resource_description-1.0.1.schema.json b/src/hydrilla/schemas/api_resource_description-1.0.1.schema.json new file mode 100644 index 0000000..7459394 --- /dev/null +++ b/src/hydrilla/schemas/api_resource_description-1.0.1.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://hydrilla.koszko.org/schemas/api_resource_description-1.0.1.schema.json", + "title": "Resource description", + "description": "Definition of a Hydrilla resource, as served through HTTP API", + "allOf": [{ + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/resource_definition_base" + }, { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/item_definition" + }, { + "type": "object", + "required": ["$schema"], + "properties": { + "$schema": { + "description": "Mark this instance as conforming to resource description schema 1.x", + "type": "string", + "pattern": "^https://hydrilla\\.koszko\\.org/schemas/api_resource_description-1\\.(([1-9][0-9]*|0)\\.)*schema\\.json$" + }, + "scripts": { + "description": "Which files are resource's scripts and need to be installed", + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/file_ref_list_sha256", + "default": [] + } + } + }] +} diff --git a/src/hydrilla/schemas/api_resource_description-1.0.1.schema.json.license b/src/hydrilla/schemas/api_resource_description-1.0.1.schema.json.license new file mode 100644 index 0000000..f41d511 --- /dev/null +++ b/src/hydrilla/schemas/api_resource_description-1.0.1.schema.json.license @@ -0,0 +1,5 @@ +SPDX-License-Identifier: CC0-1.0 + +Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> + +Available under the terms of Creative Commons Zero v1.0 Universal. diff --git a/src/hydrilla/schemas/api_source_description-1.0.1.schema.json b/src/hydrilla/schemas/api_source_description-1.0.1.schema.json new file mode 100644 index 0000000..0744d1a --- /dev/null +++ b/src/hydrilla/schemas/api_source_description-1.0.1.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://hydrilla.koszko.org/schemas/api_source_description-1.0.1.schema.json", + "title": "Source description", + "description": "Built description of a Hydrilla source package", + "type": "object", + "required": [ + "$schema", + "source_name", + "source_copyright", + "source_archives", + "upstream_url", + "definitions" + ], + "properties": { + "$schema": { + "description": "Mark this instance as conforming to source description schema 1.x", + "type": "string", + "pattern": "^https://hydrilla\\.koszko\\.org/schemas/api_source_description-1\\.(([1-9][0-9]*|0)\\.)*schema\\.json$" + }, + "source_name": { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/source_name" + }, + "source_copyright": { + "description": "Which files indicate license terms of the source package", + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/file_ref_list_sha256" + }, + "source_archives": { + "description": "What archive extensions are available for this package's sources", + "type": "object", + "required": ["zip"], + "additionalProperties": { + "description": "What is the SHA256 sum of given source archive", + "type": "object", + "required": ["sha256"], + "properties": { + "sha256": { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/sha256" + } + } + }, + "examples": [{ + "zip": { + "sha256": "688461da362ffe2fc8e85db73e709a5356d41c8aeb7d1eee7170c64ee21dd2a2" + } + }] + }, + "upstream_url": { + "description": "Where this software/work initially comes from", + "type": "string" + }, + "comment": { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/comment" + }, + "definitions": { + "description": "References to site resources and pattern->payload mappings", + "type": "array", + "items": { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/typed_item_ref" + } + }, + "generated_by": { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/generated_by" + } + } +} diff --git a/src/hydrilla/schemas/api_source_description-1.0.1.schema.json.license b/src/hydrilla/schemas/api_source_description-1.0.1.schema.json.license new file mode 100644 index 0000000..f41d511 --- /dev/null +++ b/src/hydrilla/schemas/api_source_description-1.0.1.schema.json.license @@ -0,0 +1,5 @@ +SPDX-License-Identifier: CC0-1.0 + +Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> + +Available under the terms of Creative Commons Zero v1.0 Universal. diff --git a/src/hydrilla/schemas/common_definitions-1.0.1.schema.json b/src/hydrilla/schemas/common_definitions-1.0.1.schema.json new file mode 100644 index 0000000..b803188 --- /dev/null +++ b/src/hydrilla/schemas/common_definitions-1.0.1.schema.json @@ -0,0 +1,233 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://hydrilla.koszko.org/schemas/common_definitions-1.0.1.schema.json", + "title": "Common definitions", + "description": "Definitions used by other Hydrilla schemas", + "definitions": { + "version": { + "description": "Version expressed as an array of integers", + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0 + }, + "contains": { + "type": "integer", + "minimum": 1 + }, + "minItems": 1 + }, + "source_name": { + "description": "Unique identifier of this source package", + "type": "string", + "pattern": "^[-0-9a-z.]+$" + }, + "comment": { + "description": "An optional comment", + "type": "string" + }, + "file_ref_list": { + "description": "List of simple file references", + "type": "array", + "items": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "description": "Filename relative to source package main directory; separator is '/'", + "type": "string", + "pattern": "^[^/]" + } + } + } + }, + "sha256": { + "description": "An SHA256 sum, in hexadecimal", + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "file_ref_list_sha256": { + "description": "List of file references with files' SHA256 sums included", + "allOf": [{ + "$ref": "#/definitions/file_ref_list" + }, { + "type": "array", + "items": { + "type": "object", + "required": ["sha256"], + "properties": { + "sha256": { + "$ref": "#/definitions/sha256" + } + } + } + }] + }, + "item_identifier": { + "description": "Identifier of an item (shared with other versions of the item, otherwise unique)", + "type": "string", + "pattern": "^[-0-9a-z]+$" + }, + "item_dep_specifier": { + "description": "Simple reference to an item as a dependency", + "type": "object", + "required": ["identifier"], + "properties": { + "identifier": { + "$ref": "#/definitions/item_identifier" + } + } + }, + "item_ref": { + "description": "An object containing a subset of fields from full item definition", + "type": "object", + "required": ["identifier", "long_name", "version"], + "properties": { + "identifier": { + "$ref": "#/definitions/item_identifier" + }, + "long_name": { + "description": "User-friendly alternative to the identifier", + "type": "string" + }, + "version": { + "$ref": "#/definitions/version" + } + } + }, + "typed_item_ref": { + "description": "An object containing a subset of fields from full item definition, including type", + "allOf": [{ + "$ref": "#/definitions/item_ref" + }, { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "description": "What kind of item is it (resource or mapping)", + "enum": ["resource", "mapping"] + } + } + }] + }, + "item_definition_base": { + "description": "Definition of a resource/mapping (fields common to source definitions and built definitions)", + "allOf": [{ + "$ref": "#/definitions/typed_item_ref" + }, { + "type": "object", + "required": ["description"], + "properties": { + "uuid": { + "description": "UUIDv4 of this item (shared with other versions of this item, otherwise unique)", + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "description": { + "description": "Item's description", + "type": "string" + }, + "comment": { + "$ref": "#/definitions/comment" + } + } + }] + }, + "resource_definition_base": { + "description": "Definition of a resource (fields common to source definitions and built definitions)", + "allOf": [{ + "$ref": "#/definitions/item_definition_base" + }, { + "type": "object", + "required": ["type", "revision"], + "properties": { + "type": { + "description": "Identify this item as a resource", + "const": "resource" + }, + "revision": { + "description": "Which revision of a packaging of given version of an upstream resource is this", + "type": "integer", + "minimum": 1 + }, + "scripts": { + "description": "What scripts are included in the resource", + "$ref": "#/definitions/file_ref_list", + "default": [] + }, + "dependencies": { + "description": "Which other resources this resource depends on", + "type": "array", + "items": { + "$ref": "#/definitions/item_dep_specifier" + }, + "default": [] + } + } + }] + }, + "mapping_definition_base": { + "description": "Definition of a mapping (fields common to source definitions and built definitions)", + "allOf": [{ + "$ref": "#/definitions/item_definition_base" + }, { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "description": "Identify this item as a mapping", + "const": "mapping" + }, + "payloads": { + "description": "Which payloads are to be applied to which URLs", + "additionalProperties": { + "$ref": "#/definitions/item_dep_specifier" + }, + "default": {}, + "examples": [{ + "https://hydrillabugs.koszko.org/***": { + "identifier": "helloapple" + }, + "https://*.koszko.org/***": { + "identifier": "hello-potato" + } + }] + } + } + }] + }, + "generated_by": { + "description": "Describe what software generated this instance", + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "Instance generator software name, without version" + }, + "version": { + "type": "string", + "description": "Instance generator software version, in arbitrary format" + } + } + }, + "item_definition": { + "description": "Definition of a resource/mapping (fields specific to built definitions)", + "type": "object", + "required": ["source_name", "source_copyright"], + "properties": { + "source_name": { + "$ref": "#/definitions/source_name" + }, + "source_copyright": { + "description": "Which files indicate license terms of the source package and should be installed", + "$ref": "#/definitions/file_ref_list_sha256" + }, + "generated_by": { + "$ref": "#/definitions/generated_by" + } + } + } + } +} diff --git a/src/hydrilla/schemas/common_definitions-1.0.1.schema.json.license b/src/hydrilla/schemas/common_definitions-1.0.1.schema.json.license new file mode 100644 index 0000000..f41d511 --- /dev/null +++ b/src/hydrilla/schemas/common_definitions-1.0.1.schema.json.license @@ -0,0 +1,5 @@ +SPDX-License-Identifier: CC0-1.0 + +Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> + +Available under the terms of Creative Commons Zero v1.0 Universal. diff --git a/src/hydrilla/schemas/package_source-1.0.1.schema.json b/src/hydrilla/schemas/package_source-1.0.1.schema.json new file mode 100644 index 0000000..2f9482e --- /dev/null +++ b/src/hydrilla/schemas/package_source-1.0.1.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://hydrilla.koszko.org/schemas/package_source-1.0.1.schema.json", + "title": "Package source", + "description": "Definition of a Hydrilla source package", + "type": "object", + "required": [ + "$schema", + "source_name", + "copyright", + "upstream_url", + "definitions" + ], + "properties": { + "$schema": { + "description": "Mark this instance as conforming to package source schema 1.x", + "type": "string", + "pattern": "^https://hydrilla\\.koszko\\.org/schemas/package_source-1\\.(([1-9][0-9]*|0)\\.)*schema\\.json$" + }, + "source_name": { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/source_name" + }, + "copyright": { + "description": "Which files from the source package indicate its license terms and should be included in the distribution packages", + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/file_ref_list" + }, + "upstream_url": { + "description": "Where this software/work initially comes from", + "type": "string" + }, + "comment": { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/comment" + }, + "definitions": { + "description": "Definitions of site resources and pattern->payload mappings", + "type": "array", + "items": { + "anyOf": [{ + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/resource_definition_base" + }, { + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/mapping_definition_base" + }] + } + }, + "additional_files": { + "description": "Files which should be included in the source archive produced by Hydrilla builder in addition to script and copyright files", + "$ref": "./common_definitions-1.0.1.schema.json#/definitions/file_ref_list", + "default": [] + }, + "reuse_generate_spdx_report": { + "description": "Should report.spdx be automatically generated for the package using REUSE tool", + "type": "boolean", + "default": false + } + } +} diff --git a/src/hydrilla/schemas/package_source-1.0.1.schema.json.license b/src/hydrilla/schemas/package_source-1.0.1.schema.json.license new file mode 100644 index 0000000..f41d511 --- /dev/null +++ b/src/hydrilla/schemas/package_source-1.0.1.schema.json.license @@ -0,0 +1,5 @@ +SPDX-License-Identifier: CC0-1.0 + +Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> + +Available under the terms of Creative Commons Zero v1.0 Universal. diff --git a/src/hydrilla/util/__init__.py b/src/hydrilla/util/__init__.py new file mode 100644 index 0000000..fadb81c --- /dev/null +++ b/src/hydrilla/util/__init__.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: CC0-1.0 + +# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> +# +# Available under the terms of Creative Commons Zero v1.0 Universal. + +from ._util import strip_json_comments, normalize_version, parse_version, \ + version_string, validator_for, translation diff --git a/src/hydrilla/util/_util.py b/src/hydrilla/util/_util.py new file mode 100644 index 0000000..778e78f --- /dev/null +++ b/src/hydrilla/util/_util.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Building Hydrilla packages. +# +# This file is part of Hydrilla +# +# Copyright (C) 2021, 2022 Wojtek Kosior +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# +# I, Wojtek Kosior, thereby promise not to sue for violation of this +# file's license. Although I request that you do not make use this code +# in a proprietary program, I am not going to enforce this in court. + +# Enable using with Python 3.7. +from __future__ import annotations + +import re +import json +import locale +import gettext + +from pathlib import Path +from typing import Optional, Union + +from jsonschema import RefResolver, Draft7Validator + +here = Path(__file__).resolve().parent + +_strip_comment_re = re.compile(r''' +^ # match from the beginning of each line +( # catch the part before '//' comment + (?: # this group matches either a string or a single out-of-string character + [^"/] | + " + (?: # this group matches any in-a-string character + [^"\\] | # match any normal character + \\[^u] | # match any escaped character like '\f' or '\n' + \\u[a-fA-F0-9]{4} # match an escape + )* + " + )* +) +# expect either end-of-line or a comment: +# * unterminated strings will cause matching to fail +# * bad comment (with '/' instead of '//') will be indicated by second group +# having length 1 instead of 2 or 0 +(//?|$) +''', re.VERBOSE) + +def strip_json_comments(text: str) -> str: + """ + Accept JSON text with optional C++-style ('//') comments and return the text + with comments removed. Consecutive slashes inside strings are handled + properly. A spurious single slash ('/') shall generate an error. Errors in + JSON itself shall be ignored. + """ + processed = 0 + stripped_text = [] + for line in text.split('\n'): + match = _strip_comment_re.match(line) + + if match is None: # unterminated string + # ignore this error, let json module report it + stripped = line + elif len(match[2]) == 1: + raise json.JSONDecodeError(_('bad_comment'), text, + processed + len(match[1])) + else: + stripped = match[1] + + stripped_text.append(stripped) + processed += len(line) + 1 + + return '\n'.join(stripped_text) + +def normalize_version(ver: list[int]) -> list[int]: + """Strip right-most zeroes from 'ver'. The original list is not modified.""" + new_len = 0 + for i, num in enumerate(ver): + if num != 0: + new_len = i + 1 + + return ver[:new_len] + +def parse_version(ver_str: str) -> list[int]: + """ + Convert 'ver_str' into an array representation, e.g. for ver_str="4.6.13.0" + return [4, 6, 13, 0]. + """ + return [int(num) for num in ver_str.split('.')] + +def version_string(ver: list[int], rev: Optional[int]=None) -> str: + """ + Produce version's string representation (optionally with revision), like: + 1.2.3-5 + No version normalization is performed. + """ + return '.'.join([str(n) for n in ver]) + ('' if rev is None else f'-{rev}') + +schemas = {} +for path in (here.parent / 'schemas').glob('*-1.0.1.schema.json'): + schema = json.loads(path.read_text()) + schemas[schema['$id']] = schema + +common_schema_filename = 'common_definitions-1.schema.json' +common_schema_path = here.parent / "schemas" / common_schema_filename + +resolver = RefResolver( + base_uri=f'file://{str(common_schema_path)}', + referrer=f'https://hydrilla.koszko.org/{common_schema_filename}', + store=schemas +) + +def validator_for(schema_filename: str) -> Draft7Validator: + """ + Prepare a validator for one of the schemas in '../schemas'. + + This function is not thread-safe. + """ + return Draft7Validator(resolver.resolve(schema_filename)[1], + resolver=resolver) + +def translation(localedir: Union[Path, str], lang: Optional[str]=None) \ + -> gettext.GNUTranslations: + """ + Configure translations for domain 'hydrilla-messages' and return the object + that represents them. + + If `lang` is set, look for translations for `lang`. Otherwise, try to + determine system's default language and use that. + """ + # https://stackoverflow.com/questions/3425294/how-to-detect-the-os-default-language-in-python + # But I am not going to surrender to Microbugs' nonfree, crappy OS to test + # it, to the lines inside try: may fail. + if lang is None: + try: + from ctypes.windll import kernel32 as windll + lang = locale.windows_locale[windll.GetUserDefaultUILanguage()] + except: + lang = locale.getdefaultlocale()[0] or 'en_US' + + localedir = Path(localedir) + if not (localedir / lang).is_dir(): + lang = 'en_US' + + return gettext.translation('hydrilla-messages', localedir=localedir, + languages=[lang]) + +_ = translation(here.parent / 'builder' / 'locales').gettext |