view flatpak/build_manifest.py @ 157:150085e39072

flatpak (build_manifest): version can be forced in setup_requirements + force cython version in cagou template
author Goffi <goffi@goffi.org>
date Tue, 23 Jul 2019 21:57:41 +0200
parents 0a92bb60db12
children 4fe61dcd856e
line wrap: on
line source

#!/usr/bin/env python3

import tempfile
import subprocess
from pathlib import Path
from typing import List
from dataclasses import dataclass
import hashlib
from ftplib import FTP
from urllib.parse import urlparse
from textwrap import dedent
import sys
import os
import json
import time
import argparse
import shutil
from packaging.version import parse as parse_version
import requests
from lxml import etree


CACHE_LIMIT = 3600 * 24
PYTHON_DEP_OVERRIDE = {
    "lxml": {
        "build-commands": [
            # --optimize=1 make the build fail, to be tried again once moved to Python3
            # "python2 ./setup.py install --prefix=${FLATPAK_DEST} --optimize=1"
            "python2 ./setup.py install --prefix=${FLATPAK_DEST}"
        ],
        "build-options": {
            "env": {
                "XSLT_CONFIG": "pkg-config libxslt"
            }
        },
    },
    "dbus-python": {
        "build-commands": [
            "python2 setup.py build",
            "python2 setup.py install --prefix=${FLATPAK_DEST}",
        ]
    },
    "kivy": {
        "post-install": [
            # this file is not copied normally when installing with
            # `python2 setup.py install`.
            # TODO: report upstream
            "cp kivy/setupconfig.py /app/lib/python2.7/site-packages/Kivy-*.egg/kivy/"
        ]
    },
    "pillow": {
        "build-options": {
            "arch": {
                "x86_64": {
                    "ldflags": "-L/usr/lib/x86_64-linux-gnu"
                }
            }
        }
    },
}
PYTHON_DEP_REQUIREMENTS_UPDATE = {
    # service-identity is not seen as a twisted requirement, so it's sometimes misplaced
    'twisted': ['service-identity'],
}
PYTHON_SETUP_REQUIREMENTS = [
    'setuptools',  # to have an up-to-date version
    'setuptools_scm',
    'docutils',  # needed my m2r
    'mistune',  # needed my m2r
    'm2r',  # needed by automat
]
DEFAULT_MANIFEST = {
    "runtime": "org.freedesktop.Platform",
    "runtime-version": "18.08",
    "sdk": "org.freedesktop.Sdk",
    "command": "sat_wrapper",
    "finish-args": [
        "--socket=session-bus",
        "--share=network",
        "--filesystem=home"
    ],
    "modules": [
        {
            "name": "python",
            # we use the same options as the ones of Python 2 from SDK
            "config-opts": [
                "--enable-deterministic-archives",
                "--disable-static",
                "--enable-shared",
                "--with-ensurepip=yes",
                "--with-system-expat",
                "--with-system-ffi",
                "--enable-loadable-sqlite-extensions",
                "--with-dbmliborder=gdbm",
                "--enable-unicode=ucs4",
            ],
            "post-install": [
                # stipping won't work without this
                "chmod 644 /app/lib/libpython2.7.so.1.0"
            ],
            "sources": [
                {
                    "type": "archive",
                    "url": "https://www.python.org/ftp/python/2.7.16/Python-2.7.16.tar.xz",
                    "sha256": "f222ef602647eecb6853681156d32de4450a2c39f4de93bd5b20235f2e660ed7"
                }
            ]
        }
    ]
}
SHOW_REQUIRES_HEADER = 'Requires: '
SETTINGS_KEY = '_build_settings'
APPDATA_RELEASE_DEV_TEXT = dedent("""\
    This is a development version, used as a preview.
    Please note that it is incomplete and it probably contains bugs.
    """)
OVERWRITE_WARNING = "{} already exists, do you want to overwrite it (y/N)? "


@dataclass
class Package:
    name: str
    version: str
    hash_: str
    url: str
    requirements: List[str]

    def __hash__(self):
        return hash(self.name)


def print_step(step):
    print()
    print("┌" + "─" * (len(step) + 2) + "┐")
    print("│ {} │".format(step))
    print("└" + "─" * (len(step) + 2) + "┘")
    print()


def parse_args():
    parser = argparse.ArgumentParser(
        description='Flatpak manifest builder for Salut à Toi')

    build_group = parser.add_argument_group('building', 'options used to generate the manifest')
    export_group = parser.add_argument_group('export', 'otions used to building files')

    # build group
    build_group.add_argument('-f', '--force', action="store_true",
                        help="force overwritting of existing manifest and appdata file")
    build_group.add_argument('--ignore-cache', action='append', default=[],
                        help='ignore the cache of this step ("all" to ignore all caches)')
    build_group.add_argument(
        '--deps-dir',
        help="use this directory to build_group python dependencies (it won't be deleted at "
             "the end, useful when you want to re-use it and avoir re-downloading)")
    # build_group.add_argument('--no-binary', help="don't use binary packages")

    # export group
    export_group.add_argument('-s', '--symlink', choices=['no', 'all', 'cache'],
        default='no',
        help='"no" to copy all files, "all" to symlink all files, "cache" to copy files '
             'and symlink only cache (default: "no")')
    export_group.add_argument('-F', '--force-export', action='store_true',
        help='force overwritting of existing files/symlinks when exporting (DEFAULT: '
             'existing files are skipped)')
    export_group.add_argument('-e', '--export', type=Path,
        help="export build files to this directory (DEFAULT: don't export files)")

    # common
    parser.add_argument('name', type=str, help="name of the package to build_group")
    parser.add_argument('version', type=str, help="version of the package to build_group")

    args = parser.parse_args()
    # FIXME: no_binary is currently not managed because version parser doesn't handle
    #        wheels
    args.no_binary = True

    if 'all' in args.ignore_cache and len(args.ignore_cache) != 1:
        parser.error('"all" cannot be used with step names in --ignore-cache, '
                     'use it alone')

    if args.export is not None and not args.export.is_dir():
        parser.error(f"{args.export} is not a directory!")

    return args


## useful methods ##

def get_cache(name):
    """Retrieve cache for a step

    cache will be ignored if too old, or explicitly ignored by user
    @param name(str): name of the step
    @return (object): cached data
    """
    # name of the cache without prefix, mainly used for python_deps
    shortname = name.split('__', 1)[0]

    if shortname in args.ignore_cache or 'all' in args.ignore_cache:
        print(f"ignoring cache for {shortname}")
        if shortname in args.ignore_cache:
            args.ignore_cache.remove(shortname)
        return None
    try:
        cache_name = cache[name]
        cache_updated = cache_name['updated']
    except KeyError:
        return None
    if time.time() - cache_updated > CACHE_LIMIT:
        print(f"cache for {name} is too old, we won't use it")
        return None
    print(f"cache found for {shortname}")
    return cache_name['data']


def set_cache(name, data):
    """Update cache for a step

    @param name(str): name of the step
    @param data(object): data to cache
    """
    cache_name = cache.setdefault(name, {})
    cache_name['updated'] = time.time()
    cache_name['data'] = data
    with cache_path.open('w') as f:
        json.dump(cache, f)


def get_python_package(package_name, step_name=None, step_message=None, with_pre=False,
                       version=None):
    """Generate module for a Python package

    @param package_name(str, list[str]): name of the Python package
        use list of str to get several packages at once.
        A (name, version) tuple (or any size 2 iterable) can be used to force version of a
        package.
        If package is a list with iterables, it will be modified in place to keep only the
        name as string. This is because version is not needed anymore after that, but
        name is still used to remove dependencies already installed with setup
        requirements.
    @param step_name(None, str): name of the step, None to use package_name
    @param step_message(None, str): message of the step (None to use default one)
        use empty string to disable it
    @param with_pre(bool): if True retrieve also pre-releases
    @param version(str, None): indicate the version of the package to download
        if None, the most recent version compativle with `with_pre` will be used
    """
    single = isinstance(package_name, str)
    if step_name is None:
        step_name = package_name if single else ' - '.join(package_name)
    if step_message != "":
        print_step(step_message or f"retrieving latest version of {package_name}")
    cache = get_cache(step_name)
    if cache is not None:
        return cache
    package_names = [package_name] if single else package_name
    data = []

    for idx, name in enumerate(package_names):
        if not isinstance(name, str):
            if version is not None:
                raise ValueError(
                    "forced version can't be used with packages versions (i.e. tuples)")
            name, forced_version = name
            package_names[idx] = name
        else:
            forced_version = version
        r = requests.get(f"https://pypi.org/pypi/{name}/json")
        r.raise_for_status()

        releases_data = []
        for version_raw, release_data in r.json()["releases"].items():
            if forced_version is not None and forced_version != version_raw:
                continue
            release_version = parse_version(version_raw)
            if release_version.is_prerelease and not with_pre:
                continue
            releases_data.append((release_version, release_data))

        # we sort releases by version to be sure to have latests one
        releases_data.sort(key=lambda d: d[0])

        try:
            release_version, release_data = releases_data[-1]
            package = next(r for r in release_data if r["packagetype"] == "sdist")
        except (IndexError, StopIteration):
            raise RuntimeError(f"Can't find a matching package for {name}")

        print(f"{name} {release_version} will be used")
        dep_data = {
            "name": name,
            "buildsystem": "simple",
            "build-commands": [
                "python2 setup.py install --prefix=${FLATPAK_DEST}"
            ],
            "sources": [
                {
                    "type": "archive",
                    "url": package["url"],
                    "sha256": package["digests"]["sha256"],
                }
            ],
            "ensure-writable": [
                "/lib/python2.7/site-packages/easy-install.pth",
                "/lib/python2.7/site-packages/setuptools.pth",
            ]
        }
        data.append(dep_data)

    set_cache(step_name, data)
    return data


def file_upload(filename, dest="/app/bin", src=None, replace=None, use_install=False):
    """Generate manifest modules to upload a local file

    @param filename(str): name of the local file to upload
        Note that it will be overwritted if replace is used
    @param dest(str, Path): path where the file must be installed in the container
    @param src(str, None): when replace is used, name of the source file
    @param replace(dict, None): mapping of text to replace
        Use when file must be dynamicly adapted to the container, note that src must be
        set to the name of the template file if this is used.
        e.g. {'##COMMAND##', 'cagou'} will replace all '##COMMAND##' in filename
        by 'cagou'
    @param use_install(bool): if True, install file with `install src dest`
        else, use `mv src dest`
    @return (list(dict)): modules to add to the manifest
    """
    assert isinstance(filename, str) and '/' not in filename
    print_step(f"generating {filename} module")
    # no cache for this one, to be sure to have always latest version
    filename = Path(filename)
    if src is None:
        file_to_test = filename
    else:
        src = Path(src)
        file_to_test = src
    if not file_to_test.exists():
        raise RuntimeError(
            f"{file_to_test} is missing, it is needed to build the manifest!")

    if replace:
        if src is None:
            raise ValueError(
                '"src" must be set to name of template file if replace is used')
        print(f'doing replacement in template "{src}" to dest "{filename}"')
        # there are strings to replace, we read file, do the changes and dump the
        # result in <filename>
        with open(src) as f:
            buff = f.read()

        for old, new in replace.items():
            buff = buff.replace(old, new)

        with open(filename, 'w') as f:
            f.write(buff)
    else:
        if src is not None:
            raise ValueError('"src" must not be used if replace is not used')

    with filename.open('rb') as f:
        hash_ = hashlib.sha256(f.read()).hexdigest()

    dest = Path(dest)

    dep_data = {
        "name": str(filename),
        "buildsystem": "simple",
        "build-commands": [
        ],
        "sources": [
            {
                "type": "file",
                "path": str(filename),
                "sha256": hash_,
            }
        ]
    }

    build_commands = dep_data['build-commands']
    if use_install:
        build_commands.append(f"install -Dv {filename} {dest}")
    else:
        if dest.as_posix() not in ('/app', '/app/bin'):
            # just in case the destination directory doesn't exist
            build_commands.append(f"mkdir -p {dest.parent}")
        build_commands.append(f"mv -v {filename} {dest}")

    return [dep_data]


def file_from_url(url, dest=None, step_name=None, step_message=None):
    """Generate manifest modules for a file either on a http(s) url or local

    @param url(str): url of the file to use, or local path
        if it starts with http, url will be used, else file_upload(url) will be used
    @param dest(str, None): path were the file should be copied
    """
    parsed = urlparse(url)
    if not parsed.scheme:
        return file_upload(url)
    if not parsed.scheme.startswith('http'):
        raise NotImplementedError (
            f'we can\'t use the URL "{url}", the scheme "{parsed.scheme}" is not managed')

    filepath = Path(parsed.path)
    stem = filepath.stem

    if step_name is None:
        # we use this name to easily ignore cache (with stem) while avoiding
        # conflict if we have 2 URLs with the same stem
        step_name = f"{stem}__{url}"

    if step_message is None:
        step_message = f"generating module for {stem}"

    print_step(step_message)
    cache = get_cache(step_name)
    if cache is not None:
        return cache

    r = requests.get(url)
    r.raise_for_status()
    file_hash = hashlib.sha256(r.content).hexdigest()

    dep_data = {"name": stem}

    if dest is not None:
        dest = Path(dest)
        dep_data.update({
            'buildsystem': 'simple',
            'build-commands':[
                f'mkdir -p {dest.parent}',
                f'mv "{filepath.name}" "{dest}"',
            ]
        })

    dep_data['sources'] = [
        {
            "type": "file",
            "url": url,
            "sha256": file_hash,
        }
    ]

    data = [dep_data]
    set_cache(step_name, data)
    return data


def get_requirements(pip, package_name):
    """Retrieve requirements for an installed python package

    @param pip(Path): path to pip executable to use
        package must be installed in the environment of this pip
    @param package_name(str): name of the package to retrieve
    @return(list[str]): found requirements
    """
    show_cplted = subprocess.run([pip,  "show", package_name],
                                 capture_output=True, text=True)
    show_cplted.check_returncode()

    lines = show_cplted.stdout.split('\n')
    requirement_raw = next(l for l in lines if l.startswith(SHOW_REQUIRES_HEADER))
    requirement_raw = requirement_raw[len(SHOW_REQUIRES_HEADER):]
    requirements = [canonical(p) for p in requirement_raw.split(',') if p.strip()]
    requirements_update = [
        canonical(r) for r in PYTHON_DEP_REQUIREMENTS_UPDATE.get(package_name, [])]
    new_requirements = set(requirements_update).difference(requirements)
    if new_requirements:
        print("adding extra requirements to {}: {}".format(
            package_name, ', '.join(new_requirements)))
        requirements.extend(new_requirements)
    return requirements


def resolve_requirements(package, deps_map, deps, indent=0):
    """Recursively resolve requirements

    @param package(Package): retrieve dependencies of this package
    @param deps_map(dict): map from dependency name to Package instance
    @param deps(list[package]): ordered dependencies
        this list is updated in place
    @param indent(int): use internally to print dependencies tree
    """
    if package in deps:
        return
    print("    " * indent + package.name)
    reqs_data = [deps_map[r] for r in package.requirements]

    for data in reqs_data:
        resolve_requirements(data, deps_map, deps, indent+1)

    deps.append(package)


def get_hg_id_date(path):
    """Get short identifier and date of current commit from given Mercurial repository

    version is retrieve with `hg id`, a "+" is appended after shortrev if it has
    been modified.
    @param path(str, Path): path to the repository
    @return(tuple(str, date)): found revision + iso date
    """
    hg_cplted = subprocess.run(
        ["hg",  "id", "--template", "{id|short}{dirty}\n{date|isodate}", path],
        capture_output=True, text=True)
    hg_cplted.check_returncode()
    return hg_cplted.stdout.split('\n')


def get_cache_dir():
    """Return path to directory to use for cache"""
    return Path(f"cache_{app_id}")


def canonical(name):
    """Get canonical name of a package"""
    return name.lower().replace('_', '-').strip()


modules = []
cache_path = Path.home() / Path('.cache/sat_flatpak_cache.json')
if not cache_path.exists():
    cache = {}
else:
    with cache_path.open() as f:
        cache = json.load(f)


## steps ##

def get_libxslt():
    """Generate manifest module to install libxslt (needed for lxml)"""
    step_name = 'libxslt'
    print_step("retrieving latest version of libxslt")
    cache = get_cache(step_name)
    if cache is not None:
        return cache

    ftp = FTP("xmlsoft.org")
    ftp.login()
    ftp.cwd("libxml2")
    libxslt_archives = [l for l in ftp.nlst() if 'xslt' in l
                        and l.endswith('tar.gz')
                        and 'git' not in l
                        and 'rc' not in l]
    latest_libxslt = libxslt_archives[-1]
    print(f"latest libxslt found: {latest_libxslt}")

    with tempfile.TemporaryDirectory() as tmp_dirname:
        tmp_dir = Path(tmp_dirname)
        file_path = tmp_dir / latest_libxslt
        with file_path.open('wb+') as f:
            ftp.retrbinary('RETR ' + latest_libxslt, f.write)
            f.seek(0)
            libxslt_hash = hashlib.sha256(f.read()).hexdigest()

    ftp.quit()

    print(f"libxstl hash: {libxslt_hash}")

    data = [{
        "name": "libxslt",
        "sources": [
            {
                "type": "archive",
                "url": f"ftp://xmlsoft.org/libxml2/{latest_libxslt}",
                "sha256": libxslt_hash,
            }
        ]
    }]
    set_cache(step_name, data)
    return data


def get_python_deps():
    """Generate manifest modules for python dependencies of main package"""
    step_name = f'python_deps__{app_id}'
    print_step("retrieving python dependencies")
    cache = get_cache(step_name)
    if cache is not None:
        return cache

    with tempfile.TemporaryDirectory() as tmp_dirname:
        if args.deps_dir is not None:
            # we ignore the created temporary directory is we have an other one specified
            tmp_dirname = args.deps_dir
        tmp_dir = Path(tmp_dirname)
        env_dir = tmp_dir / 'env'
        pip = env_dir / 'bin' / 'pip'
        download_dir = tmp_dir / 'archives'
        if download_dir.exists() and env_dir.exists():
            print("dependencies are already downloaded and packages are already "
                  "installed")
        else:
            download_dir.mkdir()
            print(f"working in temporary directory {tmp_dirname}")
            venv_cplted = subprocess.run(["/usr/bin/env", "virtualenv2", env_dir])
            venv_cplted.check_returncode()
            print("\ndownloading packages")
            command_args = [pip,  "download", "-d", download_dir,
                            "--progress-bar", "emoji"]
            if args.no_binary:
                command_args.extend(["--no-binary", ":all:"])
            command_args.append(main_package_source)
            down_cplted = subprocess.run(command_args)
            down_cplted.check_returncode()
            print("\ninstalling package\n")
            # we install package to have a good map of requirements, pypi metadata
            # are incomplete. Packages should be retrieved from pip cache
            inst_cplted = subprocess.run([pip, "install", main_package_source])
            inst_cplted.check_returncode()

        print_step("analyzing python dependencies")
        deps_map = {}

        for archive_path in download_dir.iterdir():
            name, right_part = archive_path.name.rsplit('-', 1)
            name_canonical = canonical(name)
            if right_part.endswith('.tar.gz'):
                version = right_part[:-7]
            elif right_part.endswith('.tar.bz2'):
                version = right_part[:-8]
            elif right_part.endswith('.zip'):
                version = right_part[:-4]
            else:
                raise ValueError(
                    f"Archive are supposed to be .tar.gz, .tar.bz2 or .zip archives, but "
                    f"file found is {archive_path.name}, did something change on Pypy?")
            with open(archive_path, "rb") as f:
                dep_hash = hashlib.sha256(f.read()).hexdigest()
            r = requests.get(f"https://pypi.org/pypi/{name}/{version}/json")
            r.raise_for_status()
            dep_json = r.json()
            release_json = dep_json["releases"][version]
            try:
                version_json = next(v for v in release_json
                                    if v['digests']['sha256'] == dep_hash)
            except IndexError:
                raise ValueError(f"Can't find the version we downloaded for {name}")

            requirements = get_requirements(pip, name_canonical)

            dep = Package(name=name_canonical,
                          version=version,
                          hash_=dep_hash,
                          url=version_json['url'],
                          requirements=requirements,
                          )

            deps_map[name_canonical] = dep
            print(f"found dependency: {dep.name} {dep.version}")

        print_step("ordering dependencies")

        requirements = get_requirements(pip, main_package)
        main_pack = Package(name=main_package,
                            version=args.version,
                            hash_="",
                            url="",
                            requirements=requirements,
                            )

    deps = []
    print("resolving requirements\n")
    resolve_requirements(main_pack, deps_map, deps)
    missing_deps = set(deps_map.values()).difference(deps)
    if missing_deps:
        print("\n/!\\ some dependencies are missing this should not happen! "
              "Adding them\n")
        print("additional requirements")
        for pack in missing_deps:
            resolve_requirements(pack, deps_map, deps)

    # we remove deps already installed with PYTHON_SETUP_REQUIREMENTS
    for name in PYTHON_SETUP_REQUIREMENTS:
        package_data = deps_map.get(name)
        if package_data is not None:
            deps.remove(package_data)
            print(f"removed {name} which is already a setup requirement")

    # main package is installed at the end
    deps.remove(main_pack)

    print("\npackages are now ordered: {}".format(", ".join(d.name for d in deps)))

    print("\nwe now generate modules for python dependencies")
    data = []
    version_force = {
        canonical(n): v
        for n,v in build_settings.get('python_deps_version_force', {}).items()
    }

    for dep in deps:
        version_forced = version_force.get(dep.name)
        if version_forced == 'pre-release':
            print(f"using pre-release version for {dep.name} as requested in build "
                  "settings")
            dep_data = get_python_package(dep.name, step_message='', with_pre=True)[0]
        elif version_forced is not None:
            print(f"using version {version_forced} for {dep.name} as requested in "
                  "build settings")
            dep_data = get_python_package(dep.name, step_message='',
                                          version=version_forced)[0]
        else:
            dep_data = {
                "name": dep.name,
                "buildsystem": "simple",
                "build-commands": [
                    "python2 setup.py install --prefix=${FLATPAK_DEST}"
                ],
                "sources": [
                    {
                        "type": "archive",
                        "url": dep.url,
                        "sha256": dep.hash_,
                    }
                ],
                "ensure-writable": [
                    "/lib/python2.7/site-packages/easy-install.pth",
                    "/lib/python2.7/site-packages/setuptools.pth",
                ]
            }

        if dep.name in PYTHON_DEP_OVERRIDE:
            print(f"applying override for {dep.name}")
            override = PYTHON_DEP_OVERRIDE[dep.name]

            # key suffixed with "_extend" won't replace the data
            for key in list(override.keys()):
                if key.endswith('_extend'):
                    real_key = key[:-7]
                    extend_data = override.pop(key)
                    if real_key == 'sources':
                        for item_data in extend_data:
                            # we look for missing hashes and add them
                            if (item_data.get('type') in ('file', 'patch')
                                and 'sha256' not in item_data):
                                with open(item_data['path'], 'rb') as f:
                                    hash_ = hashlib.sha256(f.read()).hexdigest()
                                item_data['sha256'] = hash_

                    dep_data.setdefault(real_key, []).extend(extend_data)

            dep_data.update(override)

        data.append(dep_data)

    set_cache(step_name, data)
    return data


def cache_from_repos():
    """Get and cache locally a repository and returns cache path"""
    print_step("retrieving code from repository")
    dev_repos = build_settings['dev_repos']
    repos_type = dev_repos.get('type', 'hg')
    if repos_type != 'hg':
        raise NotImplementedError("only Mercurial is managed so far")
    url = dev_repos['url']
    dest_path = get_cache_dir() / main_package
    dest_path.mkdir(parents=True, exist_ok=True)
    if (dest_path / '.hg').is_dir():
        print("code is already in cache, updating")
        hg_cplted = subprocess.run(["hg",  "pull", "-u", "--cwd", dest_path])
        hg_cplted.check_returncode()
    else:
        try:
            print(f"retrieving code from repository {url} into {dest_path}")
            hg_cplted = subprocess.run(["hg",  "clone", url, dest_path])
            hg_cplted.check_returncode()
        except Exception as e:
            shutil.rmtree(dest_path)
            raise e
    return dest_path


def get_repos_module():
    """Generate manifest module for the repository"""
    dep_data = {
        "name": main_package,
        "buildsystem": "simple",
        "build-commands": [
            "python2 setup.py install --prefix=${FLATPAK_DEST}"
        ],
        "sources": [
            {
                "type": "dir",
                "path": str(main_package_source)
            }
        ],
        "ensure-writable": [
            "/lib/python2.7/site-packages/easy-install.pth",
            "/lib/python2.7/site-packages/setuptools.pth",
        ]
    }
    return [dep_data]


def get_sat_media():
    """Generate module for last version of sat_media available on the FTP"""
    step_name = 'sat_media'
    print_step("retrieving latest version of sat_media")
    cache = get_cache(step_name)
    if cache is not None:
        return cache
    url = "https://ftp.goffi.org/sat_media/sat_media.tar.bz2"
    r = requests.get(url)
    r.raise_for_status()
    hash_ = hashlib.sha256(r.content).hexdigest()
    dep_data = {
        "name": "sat-media",
        "buildsystem": "simple",
        "build-commands": [
            "cp -vr . ${FLATPAK_DEST}/share/sat-media"
        ],
        "sources": [
            {
                "type": "archive",
                "url": url,
                "sha256": hash_,
            }
        ],
    }

    data = [dep_data]
    set_cache(step_name, data)
    return data


def get_icon():
    icon = build_settings.get('icon')
    if icon is None:
        return []
    else:
        if isinstance(icon, str):
            icon = {'url': icon}
        icon_path = Path(urlparse(icon['url']).path)
        suffix = icon_path.suffix[1:]
        if suffix not in ('svg', 'png'):
            raise ValueError("invalid icon, you must use a SVG or PNG image!")
        if 'size' not in icon:
            if suffix == 'svg':
                icon['size'] = 'scalable'
            else:
                raise ValueError('icon size is not specified, please add a "size" key')

        dest_path = f"/app/share/icons/hicolor/{icon['size']}/apps/{app_id}.{suffix}"

        data = file_from_url(
            url = icon['url'],
            dest = dest_path,
            step_name = f"icon__{app_id}",
            step_message = "retrieving application icon",
        )
        data[0]['name'] = 'icon'
        return data


def generate_appdata_from_template(template_file):
    appdata_file = Path(f"{app_id}.appdata.xml")
    if appdata_file.exists() and not args.force:
        confirm = input(OVERWRITE_WARNING.format(appdata_file))
        if confirm != 'y':
            print("manifest building cancelled")
            sys.exit(0)
    parser = etree.XMLParser(remove_blank_text=True)
    tree = etree.parse(template_file, parser)
    root = tree.getroot()
    if args.version == 'dev':
        print(f"addind release data for dev version ({dev_version_rev})")
        releases_elt = root.find('releases')
        if releases_elt is None:
            raise ValueError(
                "<releases/> element is missing in appdata template, please add it")
        release_elt = etree.SubElement(
            releases_elt,
            "release",
            {'type': 'development',
             'version': dev_version_rev,
             'date': dev_version_date},
        )
        description_elt = etree.SubElement(release_elt, 'description')
        text_lines = APPDATA_RELEASE_DEV_TEXT.strip().split('\n')
        for text in text_lines:
            etree.SubElement(description_elt, 'p').text = text

    with open(appdata_file, "wb") as f:
        f.write(etree.tostring(root, encoding="utf-8", xml_declaration=True,
                               pretty_print=True))

    return appdata_file.as_posix()


def get_app_metadata():
    desktop_file = build_settings.get('desktop_file')
    appdata_file = build_settings.get('appdata_file')
    if desktop_file is None and appdata_file is None:
        return

    print_step("retrieving application metadata")
    # we don't use cache here to be sure to have always up-to-date files

    data = []

    if desktop_file is not None:
        print("generating module for desktop metadata")
        data.extend(file_upload(
            filename = desktop_file,
            dest=f"/app/share/applications/{app_id}.desktop",
            ))

    if appdata_file is not None:
        if appdata_file.startswith('_tpl_'):
            print("found appdata template, we now use it to generate the file")
            appdata_file = generate_appdata_from_template(appdata_file)
        print("generating module for appdata metadata")
        data.extend(file_upload(
            filename = appdata_file,
            dest=f"/app/share/metainfo/{app_id}.appdata.xml",
            ))

    return data


## main_script ##


if __name__ == '__main__':

    args = parse_args()
    title = args.name.title()
    app_id = f"org.salutatoi.{title}"
    package_file = Path(f"{app_id}.json")

    print(f"generating manifest for {app_id} ({args.version})")

    if package_file.exists() and not args.force:
        confirm = input(OVERWRITE_WARNING.format(package_file))
        if confirm != 'y':
            print("manifest building cancelled")
            sys.exit(0)

    tpl_file = Path(f"_tpl_{package_file}")

    if not tpl_file.exists():
        raise RuntimeError(f"Can't find template {tpl_file}, it is mandatory to build"
                           "the manifest!")

    with tpl_file.open() as f:
        template = json.load(f)

    build_settings = template.pop(SETTINGS_KEY, {})
    if "setup_requirements" in build_settings:
        PYTHON_SETUP_REQUIREMENTS.extend(build_settings["setup_requirements"])
    main_package = canonical(build_settings.get('package', args.name))
    if args.version == 'dev':
        if 'dev_repos' not in build_settings:
            raise NotImplementedError(
                "dev version can currently only be handled with a dev repostiory "
                "(dev_repos)")
        dev_repos = build_settings['dev_repos']
        main_package_source = cache_from_repos()
        dev_version_rev, dev_version_date = get_hg_id_date(main_package_source)
    else:
        main_package_source = main_package

    manifest = {}
    manifest['app-id'] = app_id
    manifest['default-branch'] = args.version
    # we update DEFAULT_MANIFEST only now to have "app-id" and "default-branch" on the top
    # of the manifest, also we don't want modules to be set now
    default_modules = DEFAULT_MANIFEST.pop('modules', [])
    manifest.update(DEFAULT_MANIFEST)
    manifest.update(template)

    modules.extend(get_libxslt())

    # setup requirements
    modules.extend(get_python_package(
        PYTHON_SETUP_REQUIREMENTS,
        step_name=f"setup_requirements__{app_id}",
        step_message="generating modules for setup requirements")
    )

    # python dependencies
    modules.extend(get_python_deps())

    # at this point we add things specified in the template
    modules.extend(manifest.get('modules', []))
    modules = default_modules + modules
    manifest['modules'] = modules

    # sat common things
    existing_modules = {d['name'] for d in modules}
    if "sat_templates" not in existing_modules:
        modules.extend(get_python_package("sat_templates", with_pre=True))
    modules.extend(get_sat_media())
    modules.extend(file_upload('sat.conf', '/app'))

    # wrapper to launch the backend if needed
    wrapped_command = build_settings.get('wrapped_command')
    if wrapped_command:
        modules.extend(file_upload('sat_wrapper', src='sat_wrapper.py',
                                   replace={'##COMMAND##': wrapped_command},
                                   use_install=True))

    # icon
    modules.extend(get_icon())

    # desktop file and appdata file
    modules.extend(get_app_metadata())

    # now the app itself
    if args.version == 'dev':
        # mercurial is needed for dev version to install but also to
        # retrieve revision used
        modules.extend(get_python_package("mercurial"))
        modules.extend(get_repos_module())
    else:
        modules.extend(get_python_package(main_package, version=args.version))

    print_step("writing manifest")
    with package_file.open('w') as f:
        json.dump(manifest, f, indent=4)

    if args.ignore_cache and args.ignore_cache != ['all']:
        print("/!\\ those --ignore-cache arguments don't correspond to any step: {}".
              format(', '.join(args.ignore_cache)))

    print(f"manifest generated successfully at {package_file}")

    if args.export is not None:
        print_step("exporting building files")
        print(f"exporting to {args.export}\n")
        to_export = [
            ("package file", package_file),
        ]

        for m in modules:
            for s in m.get('sources', []):
                s_type = s.get('type')
                if s_type in ('path', 'file'):
                    try:
                        path = s['path']
                    except KeyError:
                        if 'url' in s:
                            continue
                        else:
                            raise ValueError(f"path missing for module source:\n{s}")
                    to_export.append((s_type, Path(path)))

        for label, path in to_export:
            print(f"exporting {label}: {path}")
            dest = args.export / path
            if dest.exists():
                if args.force_export:
                    print(f"    replacing existing {dest}")
                    if path.is_dir():
                        shutil.rmtree(dest)
                    else:
                        dest.unlink()
                else:
                    print("    it already exists, skipping")
                    continue
            if args.symlink == 'all':
                os.symlink(path, dest)
            else:
                if path.is_dir():
                    shutil.copytree(path, dest)
                else:
                    shutil.copyfile(path, dest)

        if args.version == 'dev':
            print("exporting cache")
            dest = args.export / get_cache_dir()
            if args.force_export and os.path.lexists(dest):
                print(f"    replacing existing {dest}")
                if dest.is_symlink():
                    dest.unlink()
                else:
                    shutil.rmtree(dest)

            if dest.exists():
                print("    it already exists, skipping")
            else:
                if args.symlink in ('all', 'cache'):
                    os.symlink(get_cache_dir().resolve(), dest)
                else:
                    shutil.copytree(get_cache_dir(), dest)

        print("\nexport completed")