#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# SPDX-License-Identifier: GPL-2.0
# Copyright (C) 2019-present Team LibreELEC (https://libreelec.tv)

import sys
import os
import datetime, time
import argparse
import json
import codecs
import threading
import queue
import subprocess
import multiprocessing
import signal
import fcntl, termios, struct

# Ensure we can output any old crap to stdout and stderr
sys.stdout = codecs.getwriter("utf-8")(sys.stdout.detach())
sys.stderr = codecs.getwriter("utf-8")(sys.stderr.detach())

# derive from subprocess to utilize wait4() for rusage stats
class RusagePopen(subprocess.Popen):
    def _try_wait(self, wait_flags):
        try:
            (pid, sts, ru) = os.wait4(self.pid, wait_flags)
        except OSError as e:
            if e.errno != errno.ECHILD:
                raise
            pid = self.pid
            sts = 0
        else:
            self.rusage = ru
        return (pid, sts)

def rusage_run(*popenargs, parent=None, timeout=None, **kwargs):
    with RusagePopen(*popenargs, **kwargs) as process:
        try:
            parent.child = process
            stdout, stderr = process.communicate(None, timeout=timeout)
        except subprocess.TimeoutExpired as exc:
            process.kill()
            process.wait()
            raise
        except:
            process.kill()
            raise
        retcode = process.poll()
    res = subprocess.CompletedProcess(process.args, retcode, stdout, stderr)
    res.rusage = process.rusage
    parent.child = None
    return res

class GeneratorEmpty(Exception):
    pass

class GeneratorStalled(Exception):
    pass

class Generator:
    def __init__(self, plan):
        self.work = plan

        self.totalJobs = len(plan)
        self.building = {}
        self.built = {}
        self.failed = {}
        self.removedPackages = {}

        self.check_no_deps = True

        # Transform unpack info from package:target to just package - simplifying refcount generation
        # Create a map for sections, as we don't autoremove "virtual" packages
        self.unpacks = {}
        self.sections = {}
        for job in self.work:
            (pkg_name, target) = job["name"].split(":")
            if pkg_name not in self.unpacks:
                self.unpacks[pkg_name] = job["unpacks"]
                self.sections[pkg_name] = job["section"]
                for unpack in job["unpacks"]:
                    if unpack not in self.sections:
                        self.sections[unpack] = "" # don't know section, assume not virtual

        # Count number of times each package is referenced by package:target (including itself) and
        # then recursively accumulate counts for any other packages that may be referenced
        # by "PKG_DEPENDS_UNPACK".
        # Once the refcount is zero for a package, the source directory can be removed.
        self.refcount = {}
        for job in self.work:
            (pkg_name, target) = job["name"].split(":")
            self.refcount[pkg_name] = self.refcount.get(pkg_name, 0) + 1
            for pkg_name in job["unpacks"]:
                self.addRefCounts(pkg_name)

    def canBuildJob(self, job):
        for dep in job["wants"]:
            if dep not in self.built:
                return False

        return True

    def getPackagesToRemove(self, job):
        packages = {}

        pkg_name = job["name"].split(":")[0]
        packages[pkg_name] = True
        for pkg_name in job["unpacks"]:
            self.addUnpackPackages(pkg_name, packages)

        for pkg_name in packages:
            if self.refcount[pkg_name] == 0 and \
                self.sections[pkg_name] != "virtual" and \
                pkg_name not in self.removedPackages:
                yield pkg_name

    def getPackageReferenceCounts(self, job):
        packages = {}

        pkg_name = job["name"].split(":")[0]
        packages[pkg_name] = True
        for pkg_name in job["unpacks"]:
            self.addUnpackPackages(pkg_name, packages)

        for pkg_name in packages:
            tokens = ""
            tokens += "[v]" if self.sections[pkg_name] == "virtual" else ""
            tokens += "[r]" if pkg_name in self.removedPackages else ""
            yield("%s:%d%s" % (pkg_name, self.refcount[pkg_name], tokens))

    def getFirstFailedJob(self, job):
        for dep in job["wants"]:
            if dep in self.failed:
                failedjob = self.getFirstFailedJob(self.failed[dep])
                if not failedjob:
                    return self.failed[dep]
                else:
                    return failedjob

        return None

    def getAllFailedJobs(self, job):
        flist = {}
        for dep in job["wants"]:
            if dep in self.failed:
                failedjob = self.getFirstFailedJob(self.failed[dep])
                if failedjob:
                    flist[failedjob["name"]] = failedjob
                else:
                    flist[dep] = self.failed[dep]

        return [flist[x] for x in flist]

    def getNextJob(self):
        if self.work == []:
            raise GeneratorEmpty

        # Always process jobs without dependencies first
        # until we're sure there's none left...
        if self.check_no_deps:
            for i, job in enumerate(self.work):
                if job["wants"] == []:
                    self.building[job["name"]] = True
                    del self.work[i]
                    job["failedjobs"] = self.getAllFailedJobs(job)
                    job["logfile"] = None
                    job["cmdproc"] = None
                    job["failed"] = False
                    return job

            self.check_no_deps = False

        # Process remaining jobs, trying to schedule
        # only those jobs with all their dependencies satisifed
        for i, job in enumerate(self.work):
            if self.canBuildJob(job):
                self.building[job["name"]] = True
                del self.work[i]
                job["failedjobs"] = self.getAllFailedJobs(job)
                job["logfile"] = None
                job["cmdproc"] = None
                job["failed"] = False
                return job

        raise GeneratorStalled

    # Return details about stalled jobs that can't build until the
    # currently building jobs are complete.
    def getStallInfo(self):
        for job in self.work:
            for dep in job["wants"]:
                if dep not in self.building and dep not in self.built:
                    break
            else:
                yield (job["name"], [d for d in job["wants"] if d in self.building])

    def activeJobCount(self):
        return len(self.building)

    def activeJobNames(self):
        for name in self.building:
            yield name

    def failedJobCount(self):
        return len(self.failed)

    def failedJobs(self):
        for name in self.failed:
            yield self.failed[name]

    def completedJobCount(self):
        return len(self.built)

    def totalJobCount(self):
        return self.totalJobs

    def completed(self, job):
        self.built[job["name"]] = job
        del self.building[job["name"]]

        if job["failed"]:
            self.failed[job["name"]] = job
        else:
            self.refcount[job["name"].split(":")[0]] -= 1

        for pkg_name in job["unpacks"]:
            self.delRefCounts(pkg_name)

    def removed(self, pkg_name):
        self.removedPackages[pkg_name] = True

    def addUnpackPackages(self, pkg_name, packages):
        packages[pkg_name] = True
        if pkg_name in self.unpacks:
            for p in self.unpacks[pkg_name]:
                self.addUnpackPackages(p, packages)

    def addRefCounts(self, pkg_name):
        self.refcount[pkg_name] = self.refcount.get(pkg_name, 0) + 1
        if pkg_name in self.unpacks:
            for p in self.unpacks[pkg_name]:
                self.addRefCounts(p)

    def delRefCounts(self, pkg_name):
        self.refcount[pkg_name] = self.refcount.get(pkg_name, 0) - 1
        if pkg_name in self.unpacks:
            for p in self.unpacks[pkg_name]:
                self.delRefCounts(p)

class BuildProcess(threading.Thread):
    def __init__(self, slot, maxslot, jobtotal, haltonerror, work, complete):
        threading.Thread.__init__(self, daemon=True)

        self.slot = slot
        self.maxslot = maxslot
        self.jobtotal = jobtotal
        self.haltonerror = haltonerror
        self.work = work
        self.complete = complete

        self.active = False

        self.child = None

        self.stopping = False

    def stop(self):
        self.stopping = True
        self.work.put(None)
        if self.child:
            try:
                os.killpg(os.getpgid(self.child.pid), signal.SIGTERM)
                self.child.wait()
            except:
                pass

    def isActive(self):
        return self.active == True

    def run(self):
        while not self.stopping:
            job = self.work.get(block=True)
            if job == None or self.stopping:
                break

            self.active = True

            job["slot"] = self.slot
            job["failed"] = self.execute(job)
            job["status"] = "FAIL" if job["failed"] else "DONE"
            self.complete.put(job)

            self.active = False

            if job["failed"] and self.haltonerror:
                break

    def execute(self, job):
        if job["failedjobs"]:
            flist = []
            for fjob in job["failedjobs"]:
                failedinfo = "%s,%s" % (fjob["task"], fjob["name"])
                if fjob["logfile"]:
                    failedinfo = "%s,%s" % (failedinfo, fjob["seq"])
                flist.append(failedinfo)
            failedinfo = ";".join(flist)
        else:
            failedinfo = ""

        job["args"] = ["%s/%s/pkgbuild" % (ROOT, SCRIPTS),
                       "%d" % self.slot, "%d" % job["seq"], "%d" % self.jobtotal, "%d" % self.maxslot,
                       job["task"], job["name"], failedinfo]

        job["start"] = time.time()
        returncode = 1
        try:
            if job["logfile"]:
                with open(job["logfile"], "w") as logfile:
                    cmd = rusage_run(job["args"], cwd=ROOT,
                                     stdin=subprocess.PIPE, stdout=logfile, stderr=subprocess.STDOUT,
                                     universal_newlines=True, shell=False, parent=self, start_new_session=True)
                returncode = cmd.returncode
                job["cmdproc"] = cmd
            else:
                try:
                    cmd = rusage_run(job["args"], cwd=ROOT,
                                     stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                                     universal_newlines=True, shell=False, parent=self, start_new_session=True,
                                     encoding="utf-8", errors="replace")
                    returncode = cmd.returncode
                    job["cmdproc"] = cmd
                except UnicodeDecodeError:
                    print('\nPKGBUILDER ERROR: UnicodeDecodeError while reading cmd.stdout from "%s %s"\n' % (job["task"], job["name"]), file=sys.stderr, flush=True)
        except Exception as e:
            print("\nPKGBUILDER ERROR: %s exception while executing: %s\n" % (str(e), job["args"]), file=sys.stderr, flush=True)

        job["end"] = time.time()
        job["elapsed"] = job["end"] - job["start"]

        if job["cmdproc"]:
            job["utime"] = job["cmdproc"].rusage.ru_utime
            job["stime"] = job["cmdproc"].rusage.ru_stime
        else:
            job["utime"] = job["stime"] = 0

        if job["elapsed"] == 0.0:
            job["cpu"] = 0.0
        else:
            job["cpu"] = round((job["utime"] + job["stime"]) * 100 / job["elapsed"])

        return (returncode != 0)

class Builder:
    def __init__(self, maxthreadcount, inputfilename, jobglog, loadstats, stats_interval, \
                 haltonerror=True, failimmediately=True, log_burst=True, log_combine="always", \
                 bookends=True, autoremove=False, colors=False, progress=False, debug=False, verbose=False):
        if inputfilename == "-":
            plan = json.load(sys.stdin)
        else:
            with open(inputfilename, "r") as infile:
                plan = json.load(infile)

        self.generator = Generator(plan)

        self.jobtotal = self.generator.totalJobCount()
        self.twidth = len("%d" % self.jobtotal)

        # parse threadcount
        if maxthreadcount.endswith("%"):
            self.threadcount = int(multiprocessing.cpu_count() / 100 * int(args.max_procs.replace("%","")))
        else:
            if args.max_procs == "0":
                self.threadcount = 256
            else:
                self.threadcount = int(maxthreadcount)

        self.threadcount = min(self.jobtotal, self.threadcount)
        self.threadcount = max(1, self.threadcount)

        if args.debug:
            DEBUG("THREADCOUNT#: input arg: %s, computed: %d" % (maxthreadcount, self.threadcount))

        self.joblog = jobglog
        self.loadstats = loadstats
        self.stats_interval = int(stats_interval)
        if self.stats_interval < 1:
            self.stats_interval = 60

        self.haltonerror = haltonerror
        self.failimmediately = failimmediately
        self.log_burst = log_burst
        self.log_combine = log_combine
        self.debug = debug
        self.verbose = verbose
        self.bookends = bookends
        self.autoremove = autoremove

        self.stdout_dirty = False
        self.stderr_dirty = False
        self.progress_dirty = False

        self.joblogfile = None
        self.loadstatsfile = None
        self.nextstats = 0
        self.build_start = 0

        self.work = queue.Queue()
        self.complete = queue.Queue()
        self.processes = []

        # Init all processes
        self.processes = []
        for i in range(1, self.threadcount + 1):
            self.processes.append(BuildProcess(i, self.threadcount, self.jobtotal, self.haltonerror, self.work, self.complete))

        # work and completion sequences
        self.wseq = 0
        self.cseq = 0

        self.progress = progress and sys.stderr.isatty()
        self.progress_glitch_fix = ""
        self.rows = self.columns = -1
        self.original_resize_handler = None
        if self.progress:
            self.getTerminalSize()
            self.resize_handler = signal.signal(signal.SIGWINCH, self.getTerminalSize)

        self.colors = (colors == "always" or (colors == "auto" and sys.stderr.isatty()))
        self.color_code = {}
        self.color_code["DONE"] = "\033[0;32m" #green
        self.color_code["FAIL"] = "\033[1;31m" #bold red
        self.color_code["ACTV"] = "\033[0;33m" #yellow
        self.color_code["IDLE"] = "\033[0;35m" #magenta
        self.color_code["INIT"] = "\033[0;36m" #cyan
        self.color_code["WAIT"] = "\033[0;35m" #magenta

    def build(self):
        if self.joblog:
            self.joblogfile = open(self.joblog, "w")

        if self.loadstats:
            self.loadstatsfile = open(self.loadstats, "w")

        self.build_start = time.time()

        for process in self.processes:
            process.start()

        # Queue new work until no more work is available, and all queued jobs have completed.
        while self.queueWork():
            job = self.getCompletedJob()

            self.writeJobLog(job)
            self.autoRemovePackages(job)
            self.processJobOutput(job)
            self.displayJobStatus(job)

            job["cmdproc"] = None
            job = None

        self.captureStats(finished=True)

        if self.joblogfile:
            self.joblogfile.close()

        if self.loadstatsfile:
            self.loadstatsfile.close()

        if self.generator.failedJobCount() != 0:
            if self.haltonerror and not self.failimmediately:
                failed = [job for job in self.generator.failedJobs() if job["logfile"]]
                if failed != []:
                    self.oprint("\nThe following log(s) for this failure are available:")
                    for job in failed:
                        self.oprint("  %s => %s" % (job["name"], job["logfile"]))
                    self.oprint("", flush=True)
            return False

        return True

    # Fill work queue with enough jobs to keep all processes busy.
    # Return True while jobs remain available to build, or queued jobs are still building.
    # Return False once all jobs have been queued, and finished building.
    def queueWork(self):

        # If an error has occurred and we are not ignoring errors, then return True
        # (but don't schedule new work) if we are to exit after all currently
        # active jobs have finished, otherwise return False.
        if self.haltonerror and self.generator.failedJobCount() != 0:
            if not self.failimmediately and self.generator.activeJobCount() != 0:
                freeslots = self.threadcount - self.generator.activeJobCount()
                self.show_status("WAIT", "waiting", ", ".join(self.generator.activeJobNames()))
                DEBUG("Waiting for : %d active, %d idle [%s]" % (self.generator.activeJobCount(), freeslots, ", ".join(self.generator.activeJobNames())))
                return True
            else:
                return False

        try:
            for i in range(self.generator.activeJobCount(), self.threadcount):
                job = self.generator.getNextJob()

                if self.verbose:
                    self.show_status("INIT", "submit", job["name"])

                if self.debug:
                    DEBUG("Queueing Job: %s %s" % (job["task"], job["name"]))

                self.wseq += 1
                job["seq"] = self.wseq
                if self.log_burst:
                    job["logfile"] = "%s/logs/%d.log" % (THREAD_CONTROL, job["seq"])

                self.work.put(job)

            if self.verbose:
                self.show_status("ACTV", "active", ", ".join(self.generator.activeJobNames()))

            if self.debug:
                freeslots = self.threadcount - self.generator.activeJobCount()
                DEBUG("Building Now: %d active, %d idle [%s]" % (self.generator.activeJobCount(), freeslots, ", ".join(self.generator.activeJobNames())))

        except GeneratorStalled:
            if self.verbose:
                freeslots = self.threadcount - self.generator.activeJobCount()
                pending = []
                for (i, (package, wants)) in enumerate(self.generator.getStallInfo()):
                    pending.append("%s (wants: %s)" % (package, ", ".join(wants)))
                self.show_status("ACTV", "active", ", ".join(self.generator.activeJobNames()))
                self.show_status("IDLE", "stalled", "; ".join(pending), p1=len(pending))

            if self.debug:
                freeslots = self.threadcount - self.generator.activeJobCount()
                DEBUG("Building Now: %d active, %d idle [%s]" % (self.generator.activeJobCount(), freeslots, ", ".join(self.generator.activeJobNames())))
                for (i, (package, wants)) in enumerate(self.generator.getStallInfo()):
                    item = "%-25s wants: %s" % (package, ", ".join(wants))
                    if i == 0:
                        DEBUG("Stalled Jobs: %s" % item)
                    else:
                        DEBUG("              %s" % item)

        except GeneratorEmpty:
            if self.generator.activeJobCount() == 0:
                if self.debug:
                    DEBUG("NO MORE JOBS: All jobs have completed - exiting.")
                return False
            else:
                if self.debug:
                    n = self.generator.activeJobCount()
                    DEBUG("NO MORE JOBS: Waiting on %d job%s to complete..." % (n, ["s",""][n == 1]))

        return True

    # Wait until a new job is available
    def getCompletedJob(self):
        while True:
            try:
                job = self.complete.get(block=True, timeout=self.captureStats(finished=False))
                self.generator.completed(job)

                if self.debug:
                    DEBUG("Finished Job: %s %s [%s] after %0.3f seconds" % (job["task"], job["name"], job["status"], job["elapsed"]))

                return job

            except queue.Empty:
                pass

    def captureStats(self, finished=False):
        self.displayProgress()
        if finished:
            self.clearProgress()
        self.flush()

        if not self.loadstatsfile:
            if self.progress:
                now = time.time()
                return int(now + 1) - now
            else:
                return None

        now = time.time()
        if now >= self.nextstats or finished:
            self.nextstats = int(now - (now % self.stats_interval)) + self.stats_interval

            loadavg = self.getLoad()
            procs = loadavg[3].split("/")
            meminfo = self.getMemory()

            print("%d %06d %5s %5s %5s %3s %4s %9d %2d %s" % (now, now - self.build_start, \
                  loadavg[0], loadavg[1], loadavg[2], procs[0], procs[1], meminfo["MemAvailable"], \
                  self.generator.activeJobCount(), ",".join(self.generator.activeJobNames())), \
                  file=self.loadstatsfile, flush=True)

        if self.progress:
            return min((self.nextstats - now), int(now + 1) - now)
        else:
            return (self.nextstats - now)

    def displayProgress(self):
        if self.progress:
            freeslots = self.threadcount - self.generator.activeJobCount()
            if self.jobtotal != self.generator.completedJobCount():
                percent = "%0.2f" % (100 / self.jobtotal * self.generator.completedJobCount())
            else:
                percent = "100"
            loadavg = self.getLoad()
            meminfo = self.getMemory()
            available = int(meminfo["MemAvailable"]) / 1024

            lines = [ "",
                      "%s: %5s%% | load: %s mem: %d MB | failed: %d idle: %d active: %d" % \
                          (self.secs2hms(time.time() - self.build_start), percent, \
                           loadavg[0], available, \
                           self.generator.failedJobCount(), freeslots, self.generator.activeJobCount()),
                      "Building: %s" % ", ".join(self.generator.activeJobNames())
                    ]

            columns = self.columns # in theory could change mid-loop
            output = []
            for line in lines:
                output.append(line if len(line) < columns else "%s+" % line[0:columns - 2])

            if not self.progress_glitch_fix:
                self.progress_glitch_fix = "%s\033[%dA" % ("\n" * len(output), len(output))

            # \033[?7l: disable linewrap
            # \033[0K: clear cursor to end of line (every line but last)
            # \033[0J: clear cursor to end of screen (last line)
            # \033%dA: move cursor up %d lines (move back to "home" position)
            # \033[?7h: re-enable linewrap
            #
            # When the console is resized to a narrower width, lines wider than the
            # new console width may be wrapped to a second line (depends on console
            # software, for example PuTTY) so disable line wrapping to prevent this.
            #
            self.eprint("\033[?7l%s\033[0J\r\033[%dA\033[?7h" % ("\033[0K\n".join(output), len(output) - 1),
                  end="\r", isProgress=True)
            self.progress_dirty = True

    def clearProgress(self):
        if self.progress and self.progress_dirty:
            self.progress_dirty = False
            self.eprint("\033[0J", end="")

    # Output completion info, and links to any relevant logs
    def displayJobStatus(self, job):
        self.cseq += 1
        self.show_status(job["status"], job["task"], job["name"], p1=self.cseq, p2=self.jobtotal)

        if job["failed"]:
            if job["logfile"]:
                self.eprint("\nThe following log for this failure is available:\n  %s\n" % job["logfile"])

            if job["failedjobs"] and job["failedjobs"][0]["logfile"]:
                if len(job["failedjobs"]) == 1:
                    self.eprint("The following log from the failed dependency may be relevant:")
                else:
                    self.eprint("The following logs from the failed dependencies may be relevant:")
                for fjob in job["failedjobs"]:
                    self.eprint("  %-7s %s => %s" % (fjob["task"], fjob["name"], fjob["logfile"]))
                self.eprint("")

    # If configured, send output for a job (either a logfile, or captured stdout) to stdout
    def processJobOutput(self, job):
        log_processed = False
        log_size = 0
        log_start = time.time()

        if job["logfile"]:
            if self.log_combine == "always" or (job["failed"] and self.log_combine == "fail"):
                if self.bookends:
                    self.oprint("<<< %s seq %s <<<" % (job["name"], job["seq"]))

                try:
                    with open(job["logfile"], "r", encoding="utf-8", errors="replace") as logfile:
                        for line in logfile:
                            self.oprint(line, end="")
                            log_size += len(line)
                except UnicodeDecodeError:
                    self.eprint("\nPKGBUILDER ERROR: UnicodeDecodeError while reading log file %s\n" % job["logfile"])

                if job["failed"]:
                    self.oprint("\nThe following log for this failure is available:\n  %s\n" % job["logfile"])

                if self.bookends:
                    self.oprint(">>> %s seq %s >>>" % (job["name"], job["seq"]))

                log_processed = True

        elif job["cmdproc"]:
            if self.log_combine == "always" or (job["failed"] and self.log_combine == "fail"):
                if self.bookends:
                    self.oprint("<<< %s" % job["name"])

                for line in job["cmdproc"].stdout:
                    self.oprint(line, end="")
                    log_size += len(line)

                if "autoremove" in job:
                    for line in job["autoremove"].stdout:
                        self.oprint(line, end="")
                        log_size += len(line)
                    job["autoremove"] = None

                if self.bookends:
                    self.oprint(">>> %s" % job["name"])

                log_processed = True

        if log_processed:
            self.flush()

            if self.debug:
                log_elapsed = time.time() - log_start
                log_rate = int(log_size / log_elapsed) if log_elapsed != 0 else 0
                log_data = ", %s" % "/".join(job["logfile"].split("/")[-2:]) if job["logfile"] else ""
                DEBUG("WRITING LOG : {0:,} bytes in {1:0.3f} seconds ({2:,d} bytes/sec{3:})".format(log_size, log_elapsed, log_rate, log_data))

    # Log completion stats for job
    def writeJobLog(self, job):
        if self.joblogfile:
            print("{j[status]} {j[seq]:0{width}} {j[slot]} {j[task]} {j[name]} " \
                  "{j[utime]:.{prec}f} {j[stime]:.{prec}f} {j[cpu]} " \
                  "{j[elapsed]:.{prec}f} {j[start]:.{prec}f} {j[end]:.{prec}f} {0}" \
                  .format(job["logfile"] if job["logfile"] else "",
                          j=job, prec=4, width=self.twidth),
                  file=self.joblogfile, flush=True)

    # Remove any source code directories that are no longer required.
    # Output from the subprocess is either appended to the burst logfile
    # or is captured for later output to stdout (after the correspnding logfile).
    def autoRemovePackages(self, job):
        if self.autoremove:
            if self.debug:
                DEBUG("Cleaning Pkg: %s (%s)" % (job["name"], ", ".join(self.generator.getPackageReferenceCounts(job))))

            for pkg_name in self.generator.getPackagesToRemove(job):
                DEBUG("Removing Pkg: %s" % pkg_name)
                args = ["%s/%s/autoremove" % (ROOT, SCRIPTS), pkg_name]
                if job["logfile"]:
                    with open(job["logfile"], "a") as logfile:
                        cmd = subprocess.run(args, cwd=ROOT,
                                             stdin=subprocess.PIPE, stdout=logfile, stderr=subprocess.STDOUT,
                                             universal_newlines=True, shell=False)
                else:
                    job["autoremove"] = subprocess.run(args, cwd=ROOT,
                                             stdin=subprocess.PIPE, capture_output=True,
                                             universal_newlines=True, shell=False)

                self.generator.removed(pkg_name)

    def show_status(self, status, task, data, p1=None, p2=None):
        p1 = (self.threadcount - self.generator.activeJobCount()) if p1 == None else p1
        p2 = self.generator.activeJobCount() if p2 == None else p2

        colored_status = "%s%-4s\033[0m" % (self.color_code[status], status) if self.colors else "%-4s" % status

        self.eprint("%s[%0*d/%0*d] [%s] %-7s %s" % \
            (self.progress_glitch_fix, self.twidth, p1, self.twidth, p2, colored_status, task, data))

    def stopProcesses(self):
        if self.processes:
            for process in self.processes:
                process.stop()
            self.processes = None

    def cleanup(self):
        self.clearProgress()
        self.flush()
        if self.original_resize_handler != None:
            signal.signal(signal.SIGWINCH, self.original_resize_handler)
        self.stopProcesses()

    def flush(self):
        if self.stdout_dirty:
            sys.stdout.flush()
            self.stdout_dirty = False

        if self.stderr_dirty:
            sys.stderr.flush()
            self.stderr_dirty = False

    def oprint(self, *args, flush=False, **kwargs):
        if self.progress_dirty:
            self.clearProgress()

        if self.stderr_dirty:
            sys.stderr.flush()
            self.stderr_dirty = False

        print(*args, **kwargs, file=sys.stdout, flush=flush)
        self.stdout_dirty = not flush

    def eprint(self, *args, flush=False, isProgress=False, **kwargs):
        if self.stdout_dirty:
            sys.stdout.flush()
            self.stdout_dirty = False

        if not isProgress and self.progress_dirty:
            self.clearProgress()

        print(*args, **kwargs, file=sys.stderr, flush=flush)
        self.stderr_dirty = not flush

    def getLoad(self):
        return open("/proc/loadavg", "r").readline().split()

    def getMemory(self):
        return dict((i.split()[0].rstrip(':'),int(i.split()[1])) for i in open("/proc/meminfo", "r").readlines())

    def getTerminalSize(self, signum = None, frame = None):
        h, w, hp, wp = struct.unpack('HHHH',
                                     fcntl.ioctl(sys.stderr.fileno(), termios.TIOCGWINSZ,
                                                 struct.pack('HHHH', 0, 0, 0, 0)))
        self.rows = h
        self.columns = w

    def secs2hms(self, seconds):
        min, sec = divmod(seconds, 60)
        hour, min = divmod(min, 60)
        return "%02d:%02d:%02d" % (hour, min, sec)

def DEBUG(msg):
    if DEBUG_LOG:
        print("%s: %s" % (datetime.datetime.now(), msg), file=DEBUG_LOG, flush=True)

parser = argparse.ArgumentParser(description="Run processes to build the specified JSON plan", \
                                 formatter_class=lambda prog: argparse.HelpFormatter(prog,max_help_position=25,width=90))

parser.add_argument("--max-procs", required=False, default="100%", \
                    help="Maximum number of processes to use. 0 is unlimited. Can be expressed as " \
                         "a percentage, for example 50%% (of $(nproc)). Default is 100%%.")

parser.add_argument("--plan", metavar="FILE", default="-", \
                    help="JSON formatted plan to be processed (default is to read from stdin).")

parser.add_argument("--joblog", metavar="FILE", default=None, \
                    help="File into which job completion information will be written.")

parser.add_argument("--loadstats", metavar="FILE", default=None, \
                    help="File into which load average and memory statistics will be written.")

parser.add_argument("--stats-interval", metavar="SECONDS", type=int, default=60, \
                    help="Sampling interval in seconds for --loadstats. Default is 60.")

group =  parser.add_mutually_exclusive_group()
group.add_argument("--log-burst", action="store_true", default=True, \
                    help="Burst job output into individual log files. This is the default.")
group.add_argument("--no-log-burst", action="store_false", dest="log_burst", \
                    help="Disable --log-burst, job output is only written to stdout.")

parser.add_argument("--log-combine", choices=["always", "never", "fail"], default="always", \
                    help='Choose when to send job output to stdout. "fail" will send to stdout the ' \
                         'log of failed jobs only, while "never" will not send any logs to stdout. ' \
                         'Default is "always".')

group =  parser.add_mutually_exclusive_group()
group.add_argument("--with-bookends", action="store_true", default=True, \
                    help="Top & tail combined logs with searchable markers. Default is enabled.")
group.add_argument("--without-bookends", action="store_false", dest="with_bookends", \
                    help="Disable --with-bookends")

group =  parser.add_mutually_exclusive_group()
group.add_argument("--halt-on-error", action="store_true", default=True, \
                    help="Halt on first build failure. This is the default.")
group.add_argument("--continue-on-error", action="store_false", dest="halt_on_error", \
                    help="Disable --halt-on-error and continue building.")

group =  parser.add_mutually_exclusive_group()
group.add_argument("--fail-immediately", action="store_true", default=True, \
                    help="With --halt-on-error, the build can either fail immediately or only after all " \
                         "other active jobs have finished. Default is to fail immediately.")
group.add_argument("--fail-after-active", action="store_false", dest="fail_immediately", \
                    help="With --halt-on-error, when an error occurs fail after all other active jobs have finished.")

parser.add_argument("--auto-remove", action="store_true", default=False, \
                    help="Automatically remove redundant source code directories. Default is disabled.")

parser.add_argument("--progress", action="store_true", default=False, \
                    help="Display progress information. Default is disabled")

parser.add_argument("--verbose", action="store_true", default=False, \
                    help="Output verbose information to stderr.")

parser.add_argument("--debug", action="store_true", default=False, \
                    help="Enable debug information.")

parser.add_argument("--colors", choices=["always", "never", "auto"], default="auto", \
                    help="Color code status (DONE, FAIL, etc) labels.")

args = parser.parse_args()

#---------------------------

ROOT = os.environ.get("ROOT", os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
SCRIPTS = os.environ.get("SCRIPTS", "scripts")
THREAD_CONTROL = os.environ["THREAD_CONTROL"]

if args.debug:
    debug_log = "%s/debug.log" % THREAD_CONTROL
    DEBUG_LOG = open(debug_log, "w")
    print("Debug information is being written to: %s\n" % debug_log, file=sys.stderr, flush=True)
else:
    DEBUG_LOG = None

with open("%s/parallel.pid" % THREAD_CONTROL, "w") as pid:
    print("%d" % os.getpid(), file=pid)

try:
    builder = Builder(args.max_procs, args.plan, args.joblog, args.loadstats, args.stats_interval, \
                      haltonerror=args.halt_on_error, failimmediately=args.fail_immediately, \
                      log_burst=args.log_burst, log_combine=args.log_combine, bookends=args.with_bookends, \
                      autoremove=args.auto_remove, colors=args.colors, progress=args.progress, \
                      debug=args.debug, verbose=args.verbose)

    result = builder.build()

    if DEBUG_LOG:
        DEBUG_LOG.close()

    sys.exit(0 if result else 1)
except (KeyboardInterrupt, SystemExit) as e:
    if builder:
        builder.cleanup()

    if type(e) == SystemExit:
        sys.exit(int(str(e)))
    else:
        sys.exit(1)

