# MIT License
#
# Copyright The SCons Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import unittest
import random
import math
import os

import SCons.Taskmaster.Job
from SCons.Script.Main import OptionsParser


def get_cpu_nums():
    # Linux, Unix and MacOS:
    if hasattr( os, "sysconf" ):
        if "SC_NPROCESSORS_ONLN" in os.sysconf_names:
            # Linux & Unix:
            ncpus = os.sysconf( "SC_NPROCESSORS_ONLN" )
        if isinstance(ncpus, int) and ncpus > 0:
            return ncpus
        else:  # OSX:
            return int(os.popen2("sysctl -n hw.ncpu")[1].read() )
    # Windows:
    if "NUMBER_OF_PROCESSORS" in os.environ:
        ncpus = int(os.environ["NUMBER_OF_PROCESSORS"])
    if ncpus > 0:
        return ncpus
    return 1 # Default

# a large number
num_sines = 500

# how many parallel jobs to perform for the test
num_jobs = get_cpu_nums()*2

# in case we werent able to detect num cpus for this test
# just make a hardcoded suffcient large number, though not future proof
if num_jobs == 2:
    num_jobs = 33

# how many tasks to perform for the test
num_tasks = num_jobs*5

class DummyLock:
    """fake lock class to use if threads are not supported"""
    def acquire(self):
        pass

    def release(self):
        pass

class NoThreadsException(Exception):
    """raised by the ParallelTestCase if threads are not supported"""

    def __str__(self):
        return "the interpreter doesn't support threads"

class Task:
    """A dummy task class for testing purposes."""

    def __init__(self, i, taskmaster):
        self.i = i
        self.taskmaster = taskmaster
        self.was_executed = 0
        self.was_prepared = 0

    def prepare(self):
        self.was_prepared = 1

    def _do_something(self):
        pass

    def needs_execute(self):
        return True

    def execute(self):
        self.taskmaster.test_case.assertTrue(self.was_prepared,
                                  "the task wasn't prepared")

        self.taskmaster.guard.acquire()
        self.taskmaster.begin_list.append(self.i)
        self.taskmaster.guard.release()

        # while task is executing, represent this in the parallel_list
        # and then turn it off
        self.taskmaster.parallel_list[self.i] = 1
        self._do_something()
        self.taskmaster.parallel_list[self.i] = 0

        # check if task was executing while another was also executing
        for j in range(1, self.taskmaster.num_tasks):
            if self.taskmaster.parallel_list[j + 1] == 1:
                self.taskmaster.found_parallel = True
                break

        self.was_executed = 1

        self.taskmaster.guard.acquire()
        self.taskmaster.end_list.append(self.i)
        self.taskmaster.guard.release()

    def executed(self):
        self.taskmaster.num_executed = self.taskmaster.num_executed + 1

        self.taskmaster.test_case.assertTrue(self.was_prepared,
                                  "the task wasn't prepared")
        self.taskmaster.test_case.assertTrue(self.was_executed,
                                  "the task wasn't really executed")
        self.taskmaster.test_case.assertTrue(isinstance(self, Task),
                                  "the task wasn't really a Task instance")

    def failed(self):
        self.taskmaster.num_failed = self.taskmaster.num_failed + 1
        self.taskmaster.stop = 1
        self.taskmaster.test_case.assertTrue(self.was_prepared,
                                  "the task wasn't prepared")

    def postprocess(self):
        self.taskmaster.num_postprocessed = self.taskmaster.num_postprocessed + 1

    def exception_set(self):
        pass

class RandomTask(Task):
    def _do_something(self):
        # do something that will take some random amount of time:
        for i in range(random.randrange(0, 100 + num_sines, 1)):
            x = math.sin(i)
        time.sleep(0.01)

class ExceptionTask:
    """A dummy task class for testing purposes."""

    def __init__(self, i, taskmaster):
        self.taskmaster = taskmaster
        self.was_prepared = 0

    def prepare(self):
        self.was_prepared = 1

    def needs_execute(self):
        return True

    def execute(self):
        raise Exception

    def executed(self):
        self.taskmaster.num_executed = self.taskmaster.num_executed + 1

        self.taskmaster.test_case.assertTrue(self.was_prepared,
                                  "the task wasn't prepared")
        self.taskmaster.test_case.assertTrue(self.was_executed,
                                  "the task wasn't really executed")
        self.taskmaster.test_case.assertTrue(self.__class__ is Task,
                                  "the task wasn't really a Task instance")

    def failed(self):
        self.taskmaster.num_failed = self.taskmaster.num_failed + 1
        self.taskmaster.stop = 1
        self.taskmaster.test_case.assertTrue(self.was_prepared,
                                  "the task wasn't prepared")

    def postprocess(self):
        self.taskmaster.num_postprocessed = self.taskmaster.num_postprocessed + 1

    def exception_set(self):
        self.taskmaster.exception_set()

class Taskmaster:
    """A dummy taskmaster class for testing the job classes."""

    def __init__(self, n, test_case, Task):
        """n is the number of dummy tasks to perform."""

        self.test_case = test_case
        self.stop = None
        self.num_tasks = n
        self.num_iterated = 0
        self.num_executed = 0
        self.num_failed = 0
        self.num_postprocessed = 0
        self.parallel_list = [0] * (n+1)
        self.found_parallel = False
        self.Task = Task

        # 'guard' guards 'task_begin_list' and 'task_end_list'
        try:
            import threading
            self.guard = threading.Lock()
        except ImportError:
            self.guard = DummyLock()

        # keep track of the order tasks are begun in
        self.begin_list = []

        # keep track of the order tasks are completed in
        self.end_list = []

    def next_task(self):
        if self.stop or self.all_tasks_are_iterated():
            return None
        else:
            self.num_iterated = self.num_iterated + 1
            return self.Task(self.num_iterated, self)

    def all_tasks_are_executed(self):
        return self.num_executed == self.num_tasks

    def all_tasks_are_iterated(self):
        return self.num_iterated == self.num_tasks

    def all_tasks_are_postprocessed(self):
        return self.num_postprocessed == self.num_tasks

    def tasks_were_serial(self):
        """analyze the task order to see if they were serial"""
        return not self.found_parallel

    def exception_set(self):
        pass

    def cleanup(self):
        pass


SaveThreadPool = None
ThreadPoolCallList = []


class JobTestCase(unittest.TestCase):
    """
    Setup common items needed for many Job test cases
    """
    def setUp(self) -> None:
        """
        Simulating real options parser experimental value.
        Since we're in a unit test we're actually using FakeOptionParser()
        Which has no values and no defaults.
        """
        OptionsParser.values.experimental = []


class ParallelTestCase(JobTestCase):
    def runTest(self):
        """test parallel jobs"""

        try:
            import threading
        except ImportError:
            raise NoThreadsException()

        taskmaster = Taskmaster(num_tasks, self, RandomTask)
        jobs = SCons.Taskmaster.Job.Jobs(num_jobs, taskmaster)
        jobs.run()

        self.assertTrue(not taskmaster.tasks_were_serial(),
                        "the tasks were not executed in parallel")
        self.assertTrue(taskmaster.all_tasks_are_executed(),
                        "all the tests were not executed")
        self.assertTrue(taskmaster.all_tasks_are_iterated(),
                        "all the tests were not iterated over")
        self.assertTrue(taskmaster.all_tasks_are_postprocessed(),
                        "all the tests were not postprocessed")
        self.assertFalse(taskmaster.num_failed,
                    "some task(s) failed to execute")

        # Verify that parallel jobs will pull all of the completed tasks
        # out of the queue at once, instead of one by one.  We do this by
        # replacing the default ThreadPool class with one that records the
        # order in which tasks are put() and get() to/from the pool, and
        # which sleeps a little bit before call get() to let the initial
        # tasks complete and get their notifications on the resultsQueue.

        class SleepTask(Task):
            def _do_something(self):
                time.sleep(0.01)

        global SaveThreadPool
        SaveThreadPool = SCons.Taskmaster.Job.ThreadPool

        class WaitThreadPool(SaveThreadPool):
            def put(self, task):
                ThreadPoolCallList.append('put(%s)' % task.i)
                return SaveThreadPool.put(self, task)
            def get(self):
                time.sleep(0.05)
                result = SaveThreadPool.get(self)
                ThreadPoolCallList.append('get(%s)' % result[0].i)
                return result

        SCons.Taskmaster.Job.ThreadPool = WaitThreadPool

        try:
            taskmaster = Taskmaster(3, self, SleepTask)
            jobs = SCons.Taskmaster.Job.Jobs(2, taskmaster)
            jobs.run()

            # The key here is that we get(1) and get(2) from the
            # resultsQueue before we put(3), but get(1) and get(2) can
            # be in either order depending on how the first two parallel
            # tasks get scheduled by the operating system.
            expect = [
                ['put(1)', 'put(2)', 'get(1)', 'get(2)', 'put(3)', 'get(3)'],
                ['put(1)', 'put(2)', 'get(2)', 'get(1)', 'put(3)', 'get(3)'],
            ]
            assert ThreadPoolCallList in expect, ThreadPoolCallList

        finally:
            SCons.Taskmaster.Job.ThreadPool = SaveThreadPool

class SerialTestCase(unittest.TestCase):
    def runTest(self):
        """test a serial job"""

        taskmaster = Taskmaster(num_tasks, self, RandomTask)
        jobs = SCons.Taskmaster.Job.Jobs(1, taskmaster)
        jobs.run()

        self.assertTrue(taskmaster.tasks_were_serial(),
                        "the tasks were not executed in series")
        self.assertTrue(taskmaster.all_tasks_are_executed(),
                        "all the tests were not executed")
        self.assertTrue(taskmaster.all_tasks_are_iterated(),
                        "all the tests were not iterated over")
        self.assertTrue(taskmaster.all_tasks_are_postprocessed(),
                        "all the tests were not postprocessed")
        self.assertFalse(taskmaster.num_failed,
                    "some task(s) failed to execute")


class NoParallelTestCase(JobTestCase):

    def runTest(self):
        """test handling lack of parallel support"""
        def NoParallel(tm, num, stack_size):
            raise NameError
        save_Parallel = SCons.Taskmaster.Job.LegacyParallel
        SCons.Taskmaster.Job.LegacyParallel = NoParallel
        try:
            taskmaster = Taskmaster(num_tasks, self, RandomTask)
            jobs = SCons.Taskmaster.Job.Jobs(2, taskmaster)
            self.assertTrue(jobs.num_jobs == 1,
                            "unexpected number of jobs %d" % jobs.num_jobs)
            jobs.run()
            self.assertTrue(taskmaster.tasks_were_serial(),
                            "the tasks were not executed in series")
            self.assertTrue(taskmaster.all_tasks_are_executed(),
                            "all the tests were not executed")
            self.assertTrue(taskmaster.all_tasks_are_iterated(),
                            "all the tests were not iterated over")
            self.assertTrue(taskmaster.all_tasks_are_postprocessed(),
                            "all the tests were not postprocessed")
            self.assertFalse(taskmaster.num_failed,
                        "some task(s) failed to execute")
        finally:
            SCons.Taskmaster.Job.LegacyParallel = save_Parallel


class SerialExceptionTestCase(unittest.TestCase):
    def runTest(self):
        """test a serial job with tasks that raise exceptions"""

        taskmaster = Taskmaster(num_tasks, self, ExceptionTask)
        jobs = SCons.Taskmaster.Job.Jobs(1, taskmaster)
        jobs.run()

        self.assertFalse(taskmaster.num_executed,
                    "a task was executed")
        self.assertTrue(taskmaster.num_iterated == 1,
                    "exactly one task should have been iterated")
        self.assertTrue(taskmaster.num_failed == 1,
                    "exactly one task should have failed")
        self.assertTrue(taskmaster.num_postprocessed == 1,
                    "exactly one task should have been postprocessed")


class ParallelExceptionTestCase(JobTestCase):

    def runTest(self):
        """test parallel jobs with tasks that raise exceptions"""

        taskmaster = Taskmaster(num_tasks, self, ExceptionTask)
        jobs = SCons.Taskmaster.Job.Jobs(num_jobs, taskmaster)
        jobs.run()

        self.assertFalse(taskmaster.num_executed,
                    "a task was executed")
        self.assertTrue(taskmaster.num_iterated >= 1,
                    "one or more task should have been iterated")
        self.assertTrue(taskmaster.num_failed >= 1,
                    "one or more tasks should have failed")
        self.assertTrue(taskmaster.num_postprocessed >= 1,
                    "one or more tasks should have been postprocessed")

#---------------------------------------------------------------------
# Above tested Job object with contrived Task and Taskmaster objects.
# Now test Job object with actual Task and Taskmaster objects.

import SCons.Taskmaster
import SCons.Node
import time

class DummyNodeInfo:
    def update(self, obj):
        pass

class testnode (SCons.Node.Node):
    def __init__(self):
        super().__init__()
        self.expect_to_be = SCons.Node.executed
        self.ninfo = DummyNodeInfo()

class goodnode (testnode):
    def __init__(self):
        super().__init__()
        self.expect_to_be = SCons.Node.up_to_date
        self.ninfo = DummyNodeInfo()

class slowgoodnode (goodnode):
    def prepare(self):
        # Delay to allow scheduled Jobs to run while the dispatcher
        # sleeps.  Keep this short because it affects the time taken
        # by this test.
        time.sleep(0.15)
        goodnode.prepare(self)

class badnode (goodnode):
    def __init__(self):
        super().__init__()
        self.expect_to_be = SCons.Node.failed
    def build(self, **kw):
        raise Exception('badnode exception')

class slowbadnode (badnode):
    def build(self, **kw):
        # Appears to take a while to build, allowing faster builds to
        # overlap.  Time duration is not especially important, but if
        # it is faster than slowgoodnode then these could complete
        # while the scheduler is sleeping.
        time.sleep(0.05)
        raise Exception('slowbadnode exception')

class badpreparenode (badnode):
    def prepare(self):
        raise Exception('badpreparenode exception')


class _SConsTaskTest(JobTestCase):

    def _test_seq(self, num_jobs):
        for node_seq in [
            [goodnode],
            [badnode],
            [slowbadnode],
            [slowgoodnode],
            [badpreparenode],
            [goodnode, badnode],
            [slowgoodnode, badnode],
            [goodnode, slowbadnode],
            [goodnode, goodnode, goodnode, slowbadnode],
            [goodnode, slowbadnode, badpreparenode, slowgoodnode],
            [goodnode, slowbadnode, slowgoodnode, badnode]
            ]:

            self._do_test(num_jobs, node_seq)

    def _do_test(self, num_jobs, node_seq):

        testnodes = []
        for tnum in range(num_tasks):
            testnodes.append(node_seq[tnum % len(node_seq)]())

        taskmaster = SCons.Taskmaster.Taskmaster(testnodes,
                                                 tasker=SCons.Taskmaster.AlwaysTask)

        jobs = SCons.Taskmaster.Job.Jobs(num_jobs, taskmaster)

        # Exceptions thrown by tasks are not actually propagated to
        # this level, but are instead stored in the Taskmaster.

        jobs.run()

        # Now figure out if tests proceeded correctly.  The first test
        # that fails will shutdown the initiation of subsequent tests,
        # but any tests currently queued for execution will still be
        # processed, and any tests that completed before the failure
        # would have resulted in new tests being queued for execution.

        # Apply the following operational heuristics of Job.py:
        #  0) An initial jobset of tasks will be queued before any
        #     good/bad results are obtained (from "execute" of task in
        #     thread).
        #  1) A goodnode will complete immediately on its thread and
        #     allow another node to be queued for execution.
        #  2) A badnode will complete immediately and suppress any
        #     subsequent execution queuing, but all currently queued
        #     tasks will still be processed.
        #  3) A slowbadnode will fail later.  It will block slots in
        #     the job queue.  Nodes that complete immediately will
        #     allow other nodes to be queued in their place, and this
        #     will continue until either (#2) above or until all job
        #     slots are filled with slowbadnode entries.

        # One approach to validating this test would be to try to
        # determine exactly how many nodes executed, how many didn't,
        # and the results of each, and then to assert failure on any
        # mismatch (including the total number of built nodes).
        # However, while this is possible to do for a single-processor
        # system, it is nearly impossible to predict correctly for a
        # multi-processor system and still test the characteristics of
        # delayed execution nodes.  Stated another way, multithreading
        # is inherently non-deterministic unless you can completely
        # characterize the entire system, and since that's not
        # possible here, we shouldn't try.

        # Therefore, this test will simply scan the set of nodes to
        # see if the node was executed or not and if it was executed
        # that it obtained the expected value for that node
        # (i.e. verifying we don't get failure crossovers or
        # mislabelling of results).

        for N in testnodes:
            state = N.get_state()
            self.assertTrue(state in [SCons.Node.no_state, N.expect_to_be],
                            "Node %s got unexpected result: %s" % (N, state))

        self.assertTrue([N for N in testnodes if N.get_state()],
                        "no nodes ran at all.")


class SerialTaskTest(_SConsTaskTest):
    def runTest(self):
        """test serial jobs with actual Taskmaster and Task"""
        self._test_seq(1)


class ParallelTaskTest(_SConsTaskTest):
    def runTest(self):
        """test parallel jobs with actual Taskmaster and Task"""
        self._test_seq(num_jobs)

        # Now run test with NewParallel() instead of LegacyParallel
        OptionsParser.values.experimental=['tm_v2']
        self._test_seq(num_jobs)




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

if __name__ == "__main__":
    unittest.main()



# Local Variables:
# tab-width:4
# indent-tabs-mode:nil
# End:
# vim: set expandtab tabstop=4 shiftwidth=4:
