#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Pitivi video editor # Copyright (c) 2016, Thibault Saunier # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 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 # Lesser General Public License for more details. # You should have received a copy of the GNU Lesser General Public # License along with this program; if not, see . # pylint: disable=invalid-name import argparse import configparser import json import os import re import shlex import shutil import subprocess import sys import tempfile from urllib.error import URLError from urllib.parse import urlparse from urllib.request import urlretrieve # The default branch is master because the script is used most often # for development. PITIVI_BRANCH = "master" MANIFEST_NAME = "org.pitivi.Pitivi.json" FLATPAK_REQ = [ ("flatpak", "0.10.0"), ("flatpak-builder", "1.2.2"), ] FLATPAK_VERSION = {} DEFAULT_GST_BRANCH = 'main' class Colors: HEADER = "\033[95m" OKBLUE = "\033[94m" OKGREEN = "\033[92m" WARNING = "\033[93m" FAIL = "\033[91m" ENDC = "\033[0m" class Console: quiet = False @classmethod def message(cls, str_format, *args): if cls.quiet: return if args: print(str_format % args) else: print(str_format) # Flush so that messages are printed at the right time # as we use many subprocesses. sys.stdout.flush() def remove_comments(string): pattern = r"(\".*?\"|\'.*?\')|(/\*.*?\*/|//[^\r\n]*$)" # first group captures quoted strings (double or single) # second group captures comments (//single-line or /* multi-line */) regex = re.compile(pattern, re.MULTILINE | re.DOTALL) def _replacer(match): # if the 2nd group (capturing comments) is not None, # it means we have captured a non-quoted (real) comment string. if match.group(2) is not None: return "" # so we will return empty to remove the comment return match.group(1) # captured quoted-string return regex.sub(_replacer, string) def load_manifest(manifest_path): with open(manifest_path, "r", encoding="UTF-8") as mr: contents = mr.read() contents = remove_comments(contents) manifest = json.loads(contents) return manifest def expand_manifest(manifest_path, outfile, basedir, gst_version, branchname): """Creates the manifest file.""" try: os.remove(outfile) except FileNotFoundError: pass template = load_manifest(manifest_path) if branchname == "stable": try: del template["desktop-file-name-prefix"] except KeyError: pass elif branchname == "master": template["desktop-file-name-prefix"] = "(Rolling) " else: template["desktop-file-name-prefix"] = "(%s) " % branchname Console.message("-> Generating %s against GStreamer %s", outfile, gst_version) for module in template["modules"]: if isinstance(module, str): # This is the name of a file to be included containing modules. continue if module["sources"][0]["type"] != "git": continue repo = os.path.join(basedir, module["name"]) if not os.path.exists(os.path.join(repo, ".git")): Console.message("-> Module: %s using online repo: %s", module["name"], module["sources"][0]["url"]) continue branch = subprocess.check_output( r"git branch 2>&1 | grep \*", shell=True, cwd=repo).decode( "utf-8").split(" ")[1].strip("\n") repo = "file://" + repo Console.message("-> Module: %s repo: %s branch: %s", module["name"], repo, branch) module["sources"][0]["url"] = repo module["sources"][0]["branch"] = branch with open(outfile, "w", encoding="UTF-8") as of: print(json.dumps(template, indent=4), file=of) def check_flatpak(verbose=True): for app, required_version in FLATPAK_REQ: try: output = subprocess.check_output([app, "--version"]) except (subprocess.CalledProcessError, OSError): if verbose: Console.message("\n%sYou need to install %s >= %s" " to be able to use the '%s' script.\n\n" "You can find some information about" " how to install it for your distribution at:\n" " * http://flatpak.org/%s\n", Colors.FAIL, app, required_version, sys.argv[0], Colors.ENDC) return False def comparable_version(version): return tuple(map(int, (version.split(".")))) version = output.decode("utf-8").split(" ")[1].strip("\n") current = comparable_version(version) FLATPAK_VERSION[app] = current if current < comparable_version(required_version): Console.message("\n%s%s %s required but %s found." " Please update and try again%s\n", Colors.FAIL, app, required_version, version, Colors.ENDC) return False return True class FlatpakObject: def __init__(self, user): self.user = user def flatpak(self, command, *args, show_output=False, comment=None): if comment: Console.message(comment) command = ["flatpak", command] if self.user: res = subprocess.check_output(command + ["--help"]).decode("utf-8") if "--user" in res: command.append("--user") command.extend(args) if comment: Console.message(" ".join(command)) if not show_output: return subprocess.check_output(command).decode("utf-8") return subprocess.check_call(command) class FlatpakPackages(FlatpakObject): def __init__(self, repos, user=True): FlatpakObject.__init__(self, user=user) self.repos = repos self.runtimes = self.__detect_runtimes() self.apps = self.__detect_apps() self.packages = self.runtimes + self.apps def __detect_packages(self, *args): packs = [] if FLATPAK_VERSION["flatpak"] < (1, 1, 2): out = self.flatpak("list", "-d", *args) package_defs = [line for line in out.split("\n") if line] for package_def in package_defs: splited_packaged_def = package_def.split() name, arch, branch = splited_packaged_def[0].split("/") # If installed from a file, the package is in no repo repo_name = splited_packaged_def[1] repo = self.repos.repos.get(repo_name) packs.append(FlatpakPackage(name, branch, repo, arch)) else: out = self.flatpak("list", "--columns=application,arch,branch,origin", *args) package_defs = [line for line in out.split("\n") if line] for package_def in package_defs: name, arch, branch, origin = package_def.split("\t") # If installed from a file, the package is in no repo repo = self.repos.repos.get(origin) packs.append(FlatpakPackage(name, branch, repo, arch)) return packs def __detect_runtimes(self): return self.__detect_packages("--runtime") def __detect_apps(self): return self.__detect_packages() def __iter__(self): for package in self.packages: yield package class FlatpakRepos(FlatpakObject): def __init__(self, user=True): FlatpakObject.__init__(self, user=user) # The remote repositories, name -> FlatpakRepo self.repos = {} self.update() def update(self): self.repos = {} if FLATPAK_VERSION["flatpak"] < (1, 1, 2): out = self.flatpak("remote-list", "-d") remotes = [line for line in out.split("\n") if line] for repo in remotes: for components in [repo.split(" "), repo.split("\t")]: if len(components) == 1: components = repo.split("\t") name = components[0] desc = "" url = None for elem in components[1:]: if not elem: continue parsed_url = urlparse(elem) if parsed_url.scheme: url = elem break if desc: desc += " " desc += elem if url: break if not url: Console.message("No valid URI found for: %s", repo) continue self.repos[name] = FlatpakRepo(name, url, desc, repos=self) else: out = self.flatpak("remote-list", "--columns=name,title,url") remotes = [line for line in out.split("\n") if line] for remote in remotes: name, title, url = remote.split("\t") parsed_url = urlparse(url) if not parsed_url.scheme: Console.message("No valid URI found for: %s", remote) continue self.repos[name] = FlatpakRepo(name, url, title, repos=self) self.packages = FlatpakPackages(self) def add(self, name, flatpakrepo_url, override=True): try: with tempfile.NamedTemporaryFile(mode="w") as flatpakrepo: urlretrieve(flatpakrepo_url, flatpakrepo.name) repo = configparser.ConfigParser() repo.read(flatpakrepo.name) url = repo["Flatpak Repo"]["Url"] except URLError: url = None same_name = None for tmpname, tmprepo in self.repos.items(): # If the URL is None (meaning we couldn't retrieve Repo infos) # just check if the repo names match. if url == tmprepo.url or url is None and name == tmpname: return tmprepo if name == tmpname: same_name = tmprepo if same_name: Console.message("Flatpak remote with the same name already exists: %s", same_name) if not override: Console.message("%sNote the URL is %s, not %s%s", Colors.WARNING, same_name.url, url, Colors.ENDC) return None Console.message("The URL is different. Overriding.") self.flatpak("remote-modify", name, "--url=" + url, comment="Setting repo %s URL from %s to %s" % (name, same_name.url, url)) same_name.url = url return same_name self.flatpak("remote-add", "--if-not-exists", name, "--from", flatpakrepo_url, comment="Adding repo %s" % name) self.update() return self.repos[name] class FlatpakRepo(FlatpakObject): def __init__(self, name, url, desc=None, user=True, repos=None): FlatpakObject.__init__(self, user=user) self.name = name assert name self.desc = desc self.repos = repos self.url = url class FlatpakPackage(FlatpakObject): """A flatpak app.""" def __init__(self, name, branch, repo, arch, user=True): FlatpakObject.__init__(self, user=user) self.name = name self.branch = branch self.repo = repo self.arch = arch def __str__(self): return "%s/%s/%s %s" % (self.name, self.arch, self.branch, self.repo.name) def is_installed(self): if not self.repo: # Bundle installed from file return True if len(self.name.split(".")) <= 3: self.repo.repos.update() for package in self.repo.repos.packages: if package.name == self.name and \ package.arch == self.arch and \ package.branch == self.branch: return True else: try: self.flatpak("info", self.name, self.branch) return True except subprocess.CalledProcessError: pass return False def install(self): if not self.repo: return self.flatpak("install", self.repo.name, self.name, self.branch, "--reinstall", "-y", show_output=True, comment="Installing %s" % self.name) def update(self): if not self.is_installed(): self.install() return self.flatpak("update", self.name, self.branch, "-y", show_output=True, comment="Updating %s" % self.name) def run_app(self, *args): """Starts the app represented by this instance.""" self.flatpak("run", "--branch=" + self.branch, self.name, *args, show_output=True, comment="Running %s (%s)" % (self.name, self.branch)) class PitiviFlatpak: def __init__(self): self.name = "Pitivi" self.sdk_repo = None self.runtime = None self.locale = None self.sdk = None self.packs = [] self.init = False self.update = False self.json = None self.args = [] self.build = False self.scriptdir = os.path.abspath(os.path.dirname(__file__)) self.envpath = os.environ.get("FLATPAK_ENVPATH", os.path.expanduser("~/%s-flatpak" % self.name.lower())) self.prefix = os.path.join( self.envpath, "%s-prefix" % self.name.lower()) self.repodir = os.path.join( self.envpath, "flatpak-repos", self.name.lower()) self.local_repos_path = os.path.abspath(os.path.join( self.scriptdir, os.pardir, os.pardir, os.pardir)) self.topdir = os.path.abspath(os.path.join( self.scriptdir, os.pardir, os.pardir)) self.build_name = self.name if os.path.exists(os.path.join(self.topdir, ".git")): with open(os.devnull, encoding="UTF-8") as devnull: try: branch = subprocess.check_output( "git rev-parse --abbrev-ref HEAD".split(" "), stderr=devnull, cwd=self.topdir).decode("utf-8").strip("\n") self.build_name = self.name + "." + branch self.build_name = self.build_name.replace(os.path.sep, "_") except subprocess.CalledProcessError: pass self.coredumpgdb = None self.coredumpctl_matches = "" def clean_args(self): Console.quiet = self.quiet if not check_flatpak(): sys.exit(1) repos = FlatpakRepos() self.sdk_repo = repos.add("flathub", "https://dl.flathub.org/repo/flathub.flatpakrepo") manifest_path = os.path.join(self.scriptdir, MANIFEST_NAME) manifest = load_manifest(manifest_path) if not manifest: return sdk_branch = manifest["runtime-version"] self.runtime = FlatpakPackage( "org.gnome.Platform", sdk_branch, self.sdk_repo, "x86_64") self.locale = FlatpakPackage( "org.gnome.Platform.Locale", sdk_branch, self.sdk_repo, "x86_64") self.sdk = FlatpakPackage( "org.gnome.Sdk", sdk_branch, self.sdk_repo, "x86_64") self.packs = [self.runtime, self.locale, self.sdk] if self.coredumpgdb is None and "--coredumpgdb" in sys.argv or "-gdb" in sys.argv: self.coredumpgdb = "" if self.bundle: self.build = True self.json = os.path.join(self.scriptdir, self.build_name + ".json") if not self.args: self.args.append(os.path.join(self.scriptdir, "enter-env")) def run(self): if self.clean: Console.message("Removing prefix") try: shutil.rmtree(self.prefix) except FileNotFoundError: pass missing_prefix = not os.path.exists(self.prefix) if self.init or self.update: self.install_flatpak_runtimes() if missing_prefix or self.update: self.setup_sandbox() else: if missing_prefix: Console.message("%sPrefix missing, create it with: %s --init%s", Colors.FAIL, __file__, Colors.ENDC) sys.exit(1) if self.coredumpgdb is not None: self.run_gdb() return if self.check: self.run_in_sandbox("gst-validate-launcher", os.path.join( self.topdir, "tests/ptv_testsuite.py"), "--xunit-file", "xunit.xml", exit_on_failure=True, cwd=self.topdir) if self.bundle: self.update_bundle() if not self.check and not self.init and not self.update and not self.bundle: assert self.args self.run_in_sandbox(*self.args, exit_on_failure=True) def update_bundle(self): if not os.path.exists(self.repodir): os.mkdir(self.repodir) build_export_args = ["flatpak", "build-export", self.repodir, self.prefix] if self.gpg_key: build_export_args.append("--gpg-sign=%s" % self.gpg_key) if self.commit_subject: build_export_args.append("--subject=%s" % self.commit_subject) if self.commit_body: build_export_args.append("--body=%s" % self.commit_body) build_export_args.append(self.branch) Console.message('-> Exporting repo %s %s (--body="%s" --subject="%s")', self.repodir, self.branch, self.commit_body, self.commit_subject) try: subprocess.check_call(build_export_args) except subprocess.CalledProcessError: sys.exit(1) update_repo_args = ["flatpak", "build-update-repo"] if self.generate_static_deltas: update_repo_args.append("--generate-static-deltas") update_repo_args.append(self.repodir) Console.message("Updating repo: '%s'", "' '".join(update_repo_args)) try: subprocess.check_call(update_repo_args) except subprocess.CalledProcessError: sys.exit(1) def setup_sandbox(self): """Creates and updates the sandbox.""" Console.message("Building Pitivi %s and dependencies in %s", self.branch, self.prefix) manifest_path = os.path.join(self.scriptdir, MANIFEST_NAME) expand_manifest(manifest_path, self.json, self.local_repos_path, self.gst_version, self.branch) builder_args = ["flatpak-builder", "--force-clean", "--ccache", self.prefix, self.json] if not self.bundle: builder_args.append("--build-only") try: subprocess.check_call(["flatpak-builder", "--version"]) except FileNotFoundError: Console.message("\n%sYou need to install flatpak-builder%s\n", Colors.FAIL, Colors.ENDC) sys.exit(1) subprocess.check_call(builder_args, cwd=self.scriptdir) if not os.path.isdir("mesonbuild/"): # Create the build directory. meson_args = ["meson", "mesonbuild/", "--prefix=/app", "--libdir=lib"] self.run_in_sandbox(*meson_args, exit_on_failure=True, cwd=self.topdir) # Build the buildable parts of Pitivi. ninja_args = ["ninja", "-C", "mesonbuild/"] self.run_in_sandbox(*ninja_args, exit_on_failure=True, cwd=self.topdir) def run_gdb(self): if not shutil.which("coredumpctl"): Console.message("%s'coredumpctl' not present on the system, can't run.%s" % (Colors.WARNING, Colors.ENDC)) sys.exit(1) # We need access to the host from the sandbox to run. with tempfile.NamedTemporaryFile() as coredump: with tempfile.NamedTemporaryFile() as stderr: command = ["coredumpctl", "dump"] + shlex.split(self.coredumpctl_matches) subprocess.check_call(command, stdout=coredump, stderr=stderr) with open(stderr.name, "r", encoding="UTF-8") as stderrf: stderr = stderrf.read() executable, = re.findall(".*Executable: (.*)", stderr) if not executable.startswith("/newroot"): print("Executable %s doesn't seem to be a flatpaked application." % executable, file=sys.stderr) executable = executable.replace("/newroot", "") args = ["gdb", executable, coredump.name] + shlex.split(self.coredumpgdb) self.run_in_sandbox(*args, mount_tmp=True) def run_in_sandbox(self, *args, exit_on_failure=False, cwd=None, mount_tmp=False): if not args: return flatpak_command = ["flatpak", "build", "--device=dri", "--env=PITIVI_DEVELOPMENT=1", "--env=PYTHONUSERBASE=/app/", "--env=CC=ccache gcc", "--env=CXX=ccache g++", "--filesystem=xdg-run/gvfsd", "--filesystem=xdg-run/at-spi/bus", "--share=network", "--socket=pulseaudio", "--socket=session-bus", "--socket=wayland", "--socket=x11", "--talk-name=org.freedesktop.Flatpak"] if mount_tmp: flatpak_command.append("--filesystem=/tmp/") # The forwarded environment variables. forwarded = {} for envvar, value in os.environ.items(): if envvar.split("_")[0] in ("G", "GDK", "GST", "GTK", "LC", "PITIVI") or \ envvar in ["DISPLAY", "GIT_INDEX_FILE", "LANG", "PYTHONPATH"]: forwarded[envvar] = value prefixes = { "GST_ENCODING_TARGET_PATH": "/app/share/gstreamer-1.0/encoding-profiles/:/app/share/pitivi/encoding-profiles/", "GST_PLUGIN_SYSTEM_PATH": "/app/lib/gstreamer-1.0/", "FREI0R_PATH": "/app/lib/frei0r-1/", "GST_PRESET_PATH": "/app/share/gstreamer-1.0/presets/:/app/share/pitivi/gstpresets/", } for envvar, path in prefixes.items(): value = forwarded.get(envvar, "") forwarded[envvar] = "%s:%s" % (path, value) for envvar, value in forwarded.items(): flatpak_command.append("--env=%s=%s" % (envvar, value)) flatpak_command.append(self.prefix) flatpak_command.extend(args) Console.message("Running in sandbox: %s", ' '.join(args)) Console.message(" ".join(flatpak_command)) try: subprocess.check_call(flatpak_command, cwd=cwd) except subprocess.CalledProcessError as e: if exit_on_failure: sys.exit(e.returncode) def install_flatpak_runtimes(self): for runtime in self.packs: if not runtime.is_installed(): runtime.install() else: # Update only if requested. if self.update: runtime.update() if __name__ == "__main__": app_flatpak = PitiviFlatpak() parser = argparse.ArgumentParser(prog="pitivi-flatpak") general = parser.add_argument_group("General") general.add_argument("--init", dest="init", action="store_true", help="Initialize the runtime/sdk/app and build the development environment if needed") general.add_argument("--update", dest="update", action="store_true", help="Update the runtime/sdk/app and rebuild the development environment") general.add_argument("-q", "--quiet", dest="quiet", action="store_true", help="Do not print anything") general.add_argument("args", nargs=argparse.REMAINDER, help="The command to run in the sandbox") devel = parser.add_argument_group("Development") devel.add_argument("--branch", dest="branch", help="The flatpak branch to use (stable, master...)", default="master") devel.add_argument("--gst-version", dest="gst_version", help="The GStreamer version to build.", default=DEFAULT_GST_BRANCH) devel.add_argument("--check", dest="check", help="Run unit tests once the build is done.", action="store_true") devel.add_argument("-c", "--clean", dest="clean", action="store_true", help="Clean previous builds and restart from scratch") debug_options = parser.add_argument_group("Debugging") debug_options.add_argument("-gdb", "--coredumpgdb", nargs="?", help="Activate gdb, passing extra args to it if wanted.") debug_options.add_argument("-m", "--coredumpctl-matches", default="", help="Arguments to pass to gdb.") bundling = parser.add_argument_group("Building bundle for distribution") bundling.add_argument("--bundle", dest="bundle", action="store_true", help="Create bundle repository, implies --build") bundling.add_argument( "--repo-commit-subject", dest="commit_subject", default=None, help="The commit subject to be used when updating the ostree repository") bundling.add_argument( "--repo-commit-body", dest="commit_body", default=None, help="The commit body to be used when updating the ostree repository") bundling.add_argument( "--gpg-sign", dest="gpg_key", default=None, help="The GPG key to sign the commit with (work only when --bundle is used)") bundling.add_argument( "--generate-static-deltas", dest="generate_static_deltas", action="store_true", help="Generate static deltas (check 'man flatpak-build-update-repo'" " for more information)") parser.parse_args(namespace=app_flatpak) app_flatpak.clean_args() app_flatpak.run()