aboutsummaryrefslogtreecommitdiff
# 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

class UnknownSchemaError(Exception):
    """
    Exception used to record problems with JSON documents for which not even
    the appropriate validation schema could be determined.
    """
    pass

_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}')

_schema_name_re = re.compile(r'''
(?P<name_base>[^/]*)
-
(?P<ver>
  (?P<major>[1-9][0-9]*)
  (?: # this repeated group matches the remaining version numbers
    \.
    (?:[1-9][0-9]*|0)
  )*
)
\.schema\.json
$
''', re.VERBOSE)

schema_paths = {}
for path in (here.parent / 'schemas').rglob('*.schema.json'):
    match = _schema_name_re.search(path.name)
    schema_name_base = match.group('name_base')
    schema_ver_list = match.group('ver').split('.')

    for i in range(len(schema_ver_list)):
        schema_ver = '.'.join(schema_ver_list[:i+1])
        schema_paths[f'{schema_name_base}-{schema_ver}.schema.json'] = path

for name, path in [*schema_paths.items()]:
    schema_paths[f'https://hydrilla.koszko.org/schemas/{name}'] = path

schemas = {}

def _get_schema(schema_name: str) -> dict:
    """Return loaded JSON of the requested schema. Cache results."""
    path = schema_paths[schema_name]

    if path not in schemas:
        schemas[path] = json.loads(path.read_text())

    return schemas[path]

def validator_for(schema: Union[str, dict]) -> Draft7Validator:
    """
    Prepare a validator for the provided schema.

    Other schemas under '../schemas' can be referenced.
    """
    if isinstance(schema, str):
        schema = _get_schema(schema)

    resolver = RefResolver(
        base_uri=schema['$id'],
        referrer=schema,
        handlers={'https': _get_schema}
    )

    return Draft7Validator(schema, resolver=resolver)

def load_instance_from_file(path: Path) -> tuple[dict, Optional[int]]:
    """
    Open a file and load its contents as a JSON document (with additional
    '//' comments support). Then parse its "$schema" property (if present)
    and return a tuple of the document instance and the major number of
    schema version.

    If no schema version number can be extracted, None is used instead.
    """
    instance = json.loads(strip_json_comments(path.read_text()))
    major = None

    if type(instance) is dict and type(instance.get('$schema')) is str:
        match = _schema_name_re.search(instance.get('$schema'))
        major = match and int(match.group('major'))

    return instance, major

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