#     Copyright 2021, Kay Hayen, mailto:kay.hayen@gmail.com
#
#     Part of "Nuitka", an optimizing Python compiler that is compatible and
#     integrates with CPython, but also works on its own.
#
#     Licensed under the Apache License, Version 2.0 (the "License");
#     you may not use this file except in compliance with the License.
#     You may obtain a copy of the License at
#
#        http://www.apache.org/licenses/LICENSE-2.0
#
#     Unless required by applicable law or agreed to in writing, software
#     distributed under the License is distributed on an "AS IS" BASIS,
#     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#     See the License for the specific language governing permissions and
#     limitations under the License.
#
""" Common test infrastructure functions. To be used by test runners. """

from __future__ import print_function

import ast
import atexit
import gc
import hashlib
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import threading
import time
from contextlib import contextmanager
from optparse import OptionGroup, OptionParser

from nuitka.freezer.DependsExe import getDependsExePath
from nuitka.Tracing import OurLogger, my_print
from nuitka.tree.SourceReading import readSourceCodeFromFilename
from nuitka.utils.AppDirs import getCacheDir
from nuitka.utils.Execution import (
    check_output,
    getNullInput,
    isExecutableCommand,
    withEnvironmentVarOverriden,
)
from nuitka.utils.FileOperations import (
    getFileContentByLine,
    getFileContents,
    getFileList,
    makePath,
    removeDirectory,
)

from .SearchModes import (
    SearchModeAll,
    SearchModeByPattern,
    SearchModeCoverage,
    SearchModeImmediate,
    SearchModeOnly,
    SearchModeResume,
)

test_logger = OurLogger("", base_style="blue")


def check_result(*popenargs, **kwargs):
    if "stdout" in kwargs:
        raise ValueError("stdout argument not allowed, it will be overridden.")

    process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs)
    _unused_output, _unused_err = process.communicate()
    retcode = process.poll()

    if retcode:
        return False
    else:
        return True


def goMainDir():
    # Go its own directory, to have it easy with path knowledge.
    os.chdir(os.path.dirname(os.path.abspath(sys.modules["__main__"].__file__)))


_python_version_str = None
_python_version = None
_python_arch = None
_python_executable = None
_python_vendor = None


def setup(suite="", needs_io_encoding=False, silent=False, go_main=True):
    if go_main:
        goMainDir()

    if "PYTHON" not in os.environ:
        os.environ["PYTHON"] = sys.executable

    # Allow test code to use this to make caching specific.
    os.environ["NUITKA_TEST_SUITE"] = suite

    # Allow providing 33, 27, and expand that to python2.7
    if (
        len(os.environ["PYTHON"]) == 2
        and os.environ["PYTHON"].isdigit()
        and os.name != "nt"
    ):

        os.environ["PYTHON"] = "python%s.%s" % (
            os.environ["PYTHON"][0],
            os.environ["PYTHON"][1],
        )

    if needs_io_encoding and "PYTHONIOENCODING" not in os.environ:
        os.environ["PYTHONIOENCODING"] = "utf-8"

    version_output = check_output(
        (
            os.environ["PYTHON"],
            "-c",
            """\
import sys, os;\
print(".".join(str(s) for s in list(sys.version_info)[:3]));\
print(("x86_64" if "AMD64" in sys.version else "x86") if os.name == "nt" else os.uname()[4]);\
print(sys.executable);\
print("Anaconda" if os.path.exists(os.path.join(sys.prefix, 'conda-meta')) else "Unknown")\
""",
        ),
        stderr=subprocess.STDOUT,
    )

    global _python_version_str, _python_version, _python_arch, _python_executable, _python_vendor  # singleton, pylint: disable=global-statement

    _python_version_str = version_output.split(b"\n")[0].strip()
    _python_arch = version_output.split(b"\n")[1].strip()
    _python_executable = version_output.split(b"\n")[2].strip()
    _python_vendor = version_output.split(b"\n")[3].strip()

    if str is not bytes:
        _python_version_str = _python_version_str.decode("utf-8")
        _python_arch = _python_arch.decode("utf-8")
        _python_executable = _python_executable.decode("utf-8")
        _python_vendor = _python_vendor.decode("utf-8")

    assert type(_python_version_str) is str, repr(_python_version_str)
    assert type(_python_arch) is str, repr(_python_arch)
    assert type(_python_executable) is str, repr(_python_executable)

    if not silent:
        my_print("Using concrete python", _python_version_str, "on", _python_arch)

    if "COVERAGE_FILE" not in os.environ:
        os.environ["COVERAGE_FILE"] = os.path.join(
            os.path.dirname(__file__), "..", "..", "..", ".coverage"
        )

    _python_version = tuple(int(d) for d in _python_version_str.split("."))

    return _python_version


def getPythonArch():
    return _python_arch


def getPythonVendor():
    return _python_vendor


def getPythonVersionString():
    return _python_version_str


tmp_dir = None


def getTempDir():
    # Create a temporary directory to work in, automatically remove it in case
    # it is empty in the end.
    global tmp_dir  # singleton, pylint: disable=global-statement

    if tmp_dir is None:
        tmp_dir = tempfile.mkdtemp(
            prefix=os.path.basename(
                os.path.dirname(os.path.abspath(sys.modules["__main__"].__file__))
            )
            + "-",
            dir=tempfile.gettempdir() if not os.path.exists("/var/tmp") else "/var/tmp",
        )

        def removeTempDir():
            removeDirectory(path=tmp_dir, ignore_errors=True)

        atexit.register(removeTempDir)

    return tmp_dir


def convertUsing2to3(path, force=False):
    command = [os.environ["PYTHON"], "-m", "py_compile", path]

    if not force:
        with open(path) as source_file:
            if "xrange" not in source_file.read():
                with open(os.devnull, "w") as stderr:
                    if check_result(command, stderr=stderr):
                        return path, False

    filename = os.path.basename(path)

    new_path = os.path.join(getTempDir(), filename)

    # This may already be a temp file, e.g. because of construct creation.
    try:
        shutil.copy(path, new_path)
    except shutil.Error:
        pass

    # For Python2.6 and 3.2 the -m lib2to3 was not yet supported.
    use_binary = sys.version_info[:2] in ((2, 6), (3, 2))

    if use_binary:
        # On Windows, we cannot rely on 2to3 to be in the path.
        if os.name == "nt":
            command = [
                sys.executable,
                os.path.join(os.path.dirname(sys.executable), "Tools/Scripts/2to3.py"),
            ]
        else:
            command = ["2to3"]
    else:
        command = [sys.executable, "-m", "lib2to3"]

    command += ("-w", "-n", "--no-diffs", new_path)

    with open(os.devnull, "w") as devnull:
        try:
            check_output(command, stderr=devnull)

        except subprocess.CalledProcessError:
            if os.name == "nt":
                raise

            command[0:3] = ["2to3"]

            check_output(command, stderr=devnull)

    with open(new_path) as result_file:
        data = result_file.read()

    with open(new_path, "w") as result_file:
        result_file.write("__file__ = %r\n" % os.path.abspath(path))
        result_file.write(data)

    return new_path, True


def decideFilenameVersionSkip(filename):
    """Make decision whether to skip based on filename and Python version.

    This codifies certain rules that files can have as suffixes or prefixes
    to make them be part of the set of tests executed for a version or not.

    Generally, an ening of "<major><minor>.py" indicates that it must be that
    Python version or higher. There is no need for ending in "26.py" as this
    is the minimum version anyway.

    The "_2.py" indicates a maxmimum version of 2.7, i.e. not Python 3.x, for
    language syntax no more supported.
    """

    # This will make many decisions with immediate returns.
    # pylint: disable=too-many-branches,too-many-return-statements

    assert type(filename) is str, repr(filename)

    # Skip runner scripts by default.
    if filename.startswith("run_"):
        return False

    if filename.endswith(".j2"):
        filename = filename[:-3]

    # Skip tests that require Python 2.7 at least.
    if filename.endswith("27.py") and _python_version < (2, 7):
        return False

    # Skip tests that require Python 2 at maximum.
    if filename.endswith("_2.py") and _python_version > (3,):
        return False

    # Skip tests that require Python 3.7 at maximum.
    if filename.endswith("_37.py") and _python_version > (3, 8):
        return False

    # Skip tests that require Python 3.2 at least.
    if filename.endswith("32.py") and _python_version < (3, 2):
        return False

    # Skip tests that require Python 3.3 at least.
    if filename.endswith("33.py") and _python_version < (3, 3):
        return False

    # Skip tests that require Python 3.4 at least.
    if filename.endswith("34.py") and _python_version < (3, 4):
        return False

    # Skip tests that require Python 3.5 at least.
    if filename.endswith("35.py") and _python_version < (3, 5):
        return False

    # Skip tests that require Python 3.6 at least.
    if filename.endswith("36.py") and _python_version < (3, 6):
        return False

    # Skip tests that require Python 3.7 at least.
    if filename.endswith("37.py") and _python_version < (3, 7):
        return False

    # Skip tests that require Python 3.8 at least.
    if filename.endswith("38.py") and _python_version < (3, 8):
        return False

    # Skip tests that require Python 3.9 at least.
    if filename.endswith("39.py") and _python_version < (3, 9):
        return False

    return True


def decideNeeds2to3(filename):
    return _python_version >= (3,) and not filename.endswith(
        ("32.py", "33.py", "34.py", "35.py", "36.py", "37.py", "38.py", "39.py")
    )


def _removeCPythonTestSuiteDir():
    # Cleanup, some tests apparently forget that.
    try:
        if os.path.isdir("@test"):
            removeDirectory("@test", ignore_errors=False)
        elif os.path.isfile("@test"):
            os.unlink("@test")
    except OSError:
        # TODO: Move this into removeDirectory maybe. Doing an external
        # call as last resort could be a good idea.

        # This seems to work for broken "lnk" files.
        if os.name == "nt":
            os.system("rmdir /S /Q @test")

        if os.path.exists("@test"):
            raise


def compareWithCPython(
    dirname, filename, extra_flags, search_mode, needs_2to3, on_error=None
):
    """Call the comparison tool. For a given directory filename.

    The search mode decides if the test case aborts on error or gets extra
    flags that are exceptions.

    """

    # Many cases to consider here, pylint: disable=too-many-branches

    if dirname is None:
        path = filename
    else:
        path = os.path.join(dirname, filename)

    # Apply 2to3 conversion if necessary.
    if needs_2to3:
        path, converted = convertUsing2to3(path)
    else:
        converted = False

    if os.getenv("NUITKA_TEST_INSTALLED", "") == "1":
        command = [
            sys.executable,
            "-m",
            "nuitka.tools.testing.compare_with_cpython",
            path,
            "silent",
        ]
    else:
        compare_with_cpython = os.path.join("..", "..", "bin", "compare_with_cpython")
        if os.path.exists(compare_with_cpython):
            command = [sys.executable, compare_with_cpython, path, "silent"]
        else:
            sys.exit("Error, cannot find Nuitka comparison runner.")

    if extra_flags is not None:
        command += extra_flags

    command += search_mode.getExtraFlags(dirname, filename)

    # Cleanup before and after test stage directory.
    _removeCPythonTestSuiteDir()

    try:
        result = subprocess.call(command)
    except KeyboardInterrupt:
        result = 2

    # Cleanup before and after test stage directory.
    _removeCPythonTestSuiteDir()

    if result != 0 and result != 2 and search_mode.abortOnFinding(dirname, filename):
        if on_error is not None:
            on_error(dirname, filename)

        search_mode.onErrorDetected("Error exit! %s" % result)

    if converted:
        os.unlink(path)

    if result == 2:
        sys.stderr.write("Interrupted, with CTRL-C\n")
        sys.exit(2)


def checkCompilesNotWithCPython(dirname, filename, search_mode):
    if dirname is None:
        path = filename
    else:
        path = os.path.join(dirname, filename)

    command = [_python_executable, "-mcompileall", path]

    try:
        result = subprocess.call(command)
    except KeyboardInterrupt:
        result = 2

    if result != 1 and result != 2 and search_mode.abortOnFinding(dirname, filename):
        search_mode.onErrorDetected("Error exit! %s" % result)


def checkSucceedsWithCPython(filename):
    command = [_python_executable, filename]

    with open(os.devnull, "w") as devnull:
        result = subprocess.call(command, stdout=devnull, stderr=subprocess.STDOUT)

    return result == 0


def hasDebugPython():
    # On Debian systems, these work.
    debug_python = os.path.join("/usr/bin/", os.environ["PYTHON"] + "-dbg")
    if os.path.exists(debug_python):
        return True

    # On Windows systems, these work.
    debug_python = os.environ["PYTHON"]
    if debug_python.lower().endswith(".exe"):
        debug_python = debug_python[:-4]
    debug_python = debug_python + "_d.exe"
    if os.path.exists(debug_python):
        return True

    # For other Python, if it's the one also executing the runner, which is
    # very probably the case, we check that. We don't check the provided
    # binary here, this could be done as well.
    if sys.executable == os.environ["PYTHON"] and hasattr(sys, "gettotalrefcount"):
        return True

    # Otherwise no.
    return False


def displayRuntimeTraces(logger, path):
    if not os.path.exists(path):
        # TODO: Have a logger package passed.
        logger.sysexit("Error, cannot find %r (%r)." % (path, os.path.abspath(path)))

    path = os.path.abspath(path)

    # TODO: Merge code for building command with below function, this is otherwise
    # horribly bad.

    if os.name == "posix":
        # Run with traces to help debugging, specifically in CI environment.
        if sys.platform == "darwin" or sys.platform.startswith("freebsd"):
            test_logger.info("dtruss:")
            os.system("sudo dtruss %s" % path)
        else:
            test_logger.info("strace:")
            os.system("strace -s4096 -e file %s" % path)


def getRuntimeTraceOfLoadedFiles(logger, path):
    """ Returns the files loaded when executing a binary. """

    # This will make a crazy amount of work,
    # pylint: disable=I0021,too-many-branches,too-many-locals,too-many-statements

    if not os.path.exists(path):
        # TODO: Have a logger package passed.
        logger.sysexit("Error, cannot find %r (%r)." % (path, os.path.abspath(path)))

    result = []

    if os.name == "posix":
        if sys.platform == "darwin" or sys.platform.startswith("freebsd"):
            if not isExecutableCommand("dtruss"):
                sys.exit(
                    """\
Error, needs 'dtruss' on your system to scan used libraries."""
                )

            if not isExecutableCommand("sudo"):
                sys.exit(
                    """\
Error, needs 'sudo' on your system to scan used libraries."""
                )

            args = ("sudo", "dtruss", "-t", "open", path)
        else:
            if not isExecutableCommand("strace"):
                sys.exit(
                    """\
Error, needs 'strace' on your system to scan used libraries."""
                )

            args = (
                "strace",
                "-e",
                "file",
                "-s4096",  # Some paths are truncated in output otherwise
                os.path.abspath(path),
            )

        # Ensure executable is not polluted with third party stuff,
        # tests may fail otherwise due to unexpected libs being loaded
        with withEnvironmentVarOverriden("LD_PRELOAD", None):
            tracing_command = args[0] if args[0] != "sudo" else args[1]

            process = subprocess.Popen(
                args=args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
            )

            _stdout_strace, stderr_strace = process.communicate()
            exit_strace = process.returncode

            if exit_strace != 0:
                if str is not bytes:
                    stderr_strace = stderr_strace.decode("utf8")

                logger.warning(stderr_strace)
                logger.sysexit("Failed to run %r." % tracing_command)

            with open(path + ".strace", "wb") as f:
                f.write(stderr_strace)

            for line in stderr_strace.split(b"\n"):
                if process.returncode != 0:
                    logger.my_print(line)

                if not line:
                    continue

                # Don't consider files not found. The "site" module checks lots
                # of things.
                if b"ENOENT" in line:
                    continue

                if line.startswith(b"stat(") and b"S_IFDIR" in line:
                    continue

                # Allow stats on the python binary, and stuff pointing to the
                # standard library, just not uses of it. It will search there
                # for stuff.
                if (
                    line.startswith(b"lstat(")
                    or line.startswith(b"stat(")
                    or line.startswith(b"readlink(")
                ):
                    filename = line[line.find(b"(") + 2 : line.find(b", ") - 1]

                    # At least Python3.7 considers the default Python3 path.
                    if filename == b"/usr/bin/python3":
                        continue

                    if filename in (
                        b"/usr/bin/python3." + version
                        for version in (b"5", b"6", b"7", b"8", b"9")
                    ):
                        continue

                    binary_path = _python_executable
                    if str is not bytes:
                        binary_path = binary_path.encode("utf-8")

                    found = False
                    while binary_path:
                        if filename == binary_path:
                            found = True
                            break

                        if binary_path == os.path.dirname(binary_path):
                            break

                        binary_path = os.path.dirname(binary_path)

                        if filename == os.path.join(
                            binary_path,
                            b"python"
                            + (
                                "%d%d" % (_python_version[0], _python_version[1])
                            ).encode("utf8"),
                        ):
                            found = True
                            continue

                    if found:
                        continue

                result.extend(
                    os.path.abspath(match)
                    for match in re.findall(b'"(.*?)(?:\\\\0)?"', line)
                )

            if sys.version.startswith("3"):
                result = [s.decode("utf-8") for s in result]
    elif os.name == "nt":
        command = (
            getDependsExePath(),
            "-c",  # Console mode
            "-ot%s" % path + ".depends",
            "-f1",
            "-pb",
            "-pa1",  # Turn on all profiling options.
            "-ps1",  # Simulate ShellExecute with app dirs in PATH.
            "-pp1",  # Do not long DllMain calls.
            "-po1",  # Log DllMain call for all other messages.
            "-ph1",  # Hook the process.
            "-pl1",  # Log LoadLibrary calls.
            "-pt1",  # Thread information.
            "-pe1",  # First chance exceptions.
            "-pg1",  # Log GetProcAddress calls.
            "-pf1",  # Use full paths.
            "-pc1",  # Profile child processes.
            path,
        )

        subprocess.call(command)

        inside = False
        for line in getFileContentByLine(path + ".depends"):
            if "| Module Dependency Tree |" in line:
                inside = True
                continue

            if not inside:
                continue

            if "| Module List |" in line:
                break

            if "]" not in line:
                continue

            # Skip missing DLLs, apparently not needed anyway.
            if "?" in line[: line.find("]")]:
                continue

            dll_filename = line[line.find("]") + 2 :].rstrip()
            dll_filename = os.path.normcase(dll_filename)

            assert os.path.isfile(dll_filename), repr(dll_filename)

            # The executable itself is of course exempted.
            if dll_filename == os.path.normcase(os.path.abspath(path)):
                continue

            result.append(dll_filename)

        os.unlink(path + ".depends")

    result = list(sorted(set(result)))

    return result


def checkRuntimeLoadedFilesForOutsideAccesses(loaded_filenames, white_list):
    # A lot of special white listing is required.
    # pylint: disable=too-many-branches,too-many-statements

    result = []

    for loaded_filename in loaded_filenames:
        loaded_filename = os.path.normpath(loaded_filename)
        loaded_filename = os.path.normcase(loaded_filename)
        loaded_basename = os.path.basename(loaded_filename)

        ok = False
        for entry in white_list:
            if loaded_filename.startswith(entry):
                ok = True

            while entry:
                old_entry = entry
                entry = os.path.dirname(entry)

                if old_entry == entry:
                    break

                if loaded_filename == entry:
                    ok = True
                    break
        if ok:
            continue

        if loaded_filename.startswith(("/etc/", "/usr/etc")):
            continue

        if loaded_filename.startswith("/proc/") or loaded_filename == "/proc":
            continue

        if loaded_filename.startswith("/dev/"):
            continue

        if loaded_filename.startswith("/tmp/"):
            continue

        if loaded_filename.startswith("/run/"):
            continue

        if loaded_filename.startswith("/sys/"):
            continue

        if loaded_filename.startswith("/usr/lib/locale/"):
            continue

        if loaded_filename.startswith("/usr/share/locale/"):
            continue

        if loaded_filename.startswith("/usr/share/X11/locale/"):
            continue

        # Themes may of course be loaded.
        if loaded_filename.startswith("/usr/share/themes"):
            continue
        if "gtk" in loaded_filename and "/engines/" in loaded_filename:
            continue

        # Terminal info files are OK too.
        if loaded_filename.startswith("/lib/terminfo/"):
            continue

        # System C libraries are to be expected.
        if loaded_basename.startswith(
            (
                "ld-linux-x86-64.so",
                "libc.so.",
                "libpthread.so.",
                "libm.so.",
                "libdl.so.",
                "libBrokenLocale.so.",
                "libSegFault.so",
                "libanl.so.",
                "libcidn.so.",
                "libcrypt.so.",
                "libmemusage.so",
                "libmvec.so.",
                "libnsl.so.",
                "libnss_compat.so.",
                "libnss_db.so.",
                "libnss_dns.so.",
                "libnss_files.so.",
                "libnss_hesiod.so.",
                "libnss_nis.so.",
                "libnss_nisplus.so.",
                "libpcprofile.so",
                "libresolv.so.",
                "librt.so.",
                "libthread_db-1.0.so",
                "libthread_db.so.",
                "libutil.so.",
            )
        ):
            continue

        # Taking these from system is harmless and desirable
        if loaded_basename.startswith(("libz.so", "libgcc_s.so")):
            continue

        # TODO: Unclear, loading gconv from filesystem of installed system
        # may be OK or not. I think it should be.
        if loaded_basename == "gconv-modules.cache":
            continue
        if "/gconv/" in loaded_filename:
            continue
        if loaded_basename.startswith("libicu"):
            continue

        # GTK may access X files.
        if loaded_basename == ".Xauthority":
            continue

        result.append(loaded_filename)

    return result


def hasModule(module_name):
    with open(os.devnull, "w") as devnull:
        result = subprocess.call(
            (os.environ["PYTHON"], "-c", "import %s" % module_name),
            stdout=devnull,
            stderr=subprocess.STDOUT,
        )

    return result == 0


m1 = {}
m2 = {}


def cleanObjRefCntMaps():
    m1.clear()
    m2.clear()

    # Warm out repr
    for x in gc.get_objects():
        try:
            str(x)
        except Exception:  # Catch all the things, pylint: disable=broad-except
            pass


def snapObjRefCntMap(before):
    # Inherently complex, pylint: disable=too-many-branches

    if before:
        m = m1
    else:
        m = m2

    m.clear()
    gc.collect()

    for x in gc.get_objects():
        # The dictionary is cyclic, and contains itself, avoid that.
        if x is m1 or x is m2:
            continue

        if type(x) is str and (x in m1 or x in m2):
            continue

        if type(x) is not str and isinstance(x, str):
            k = "str_overload_" + x.__class__.__name__ + str(x)
        elif type(x) is dict:
            if "__builtins__" in x:
                k = "<module dict %s>" % x["__name__"]
            elif "__spec__" in x and "__name__" in x:
                k = "<module dict %s>" % x["__name__"]
            else:
                k = str(x)
        elif x.__class__.__name__ == "compiled_frame":
            k = "<compiled_frame at xxx, line %d code %s" % (x.f_lineno, x.f_code)
        else:
            k = str(x)

        c = sys.getrefcount(x)

        if k in m:
            m[k] += c
        else:
            m[k] = c


orig_print = None


def disablePrinting():
    # Singleton, pylint: disable=global-statement
    global orig_print

    if orig_print is None:
        orig_print = __builtins__["print"]
        __builtins__["print"] = lambda *args, **kwargs: None


def reenablePrinting():
    # Singleton, pylint: disable=global-statement
    global orig_print

    if orig_print is not None:
        __builtins__["print"] = orig_print
        orig_print = None


_debug_python = hasattr(sys, "gettotalrefcount")


def getTotalReferenceCount():
    if _debug_python:
        gc.collect()
        return sys.gettotalrefcount()
    else:
        gc.collect()
        all_objects = gc.get_objects()

        # Sum object reference twice, once without the sum value type, then switch
        # the type, and use the type used to avoid the integers before that.
        result = 0.0
        for obj in all_objects:
            if type(obj) is float:
                continue

            result += sys.getrefcount(obj)

        result = int(result)

        for obj in all_objects:
            if type(obj) is not float:
                continue

            result += sys.getrefcount(obj)

        return result


def checkReferenceCount(checked_function, max_rounds=20, explain=False):
    # This is obviously going to be complex, pylint: disable=too-many-branches

    # Clean start conditions.
    assert sys.exc_info() == (None, None, None), sys.exc_info()

    print(checked_function.__name__ + ": ", end="")
    sys.stdout.flush()

    disablePrinting()

    # Make sure reference for these are already taken at the start.
    ref_count1 = 17
    ref_count2 = 17

    if explain:
        cleanObjRefCntMaps()

    assert max_rounds > 0

    result = False

    for count in range(max_rounds):
        if explain and count == max_rounds - 1:
            snapObjRefCntMap(before=True)

        ref_count1 = getTotalReferenceCount()

        checked_function()

        ref_count2 = getTotalReferenceCount()

        # Not allowed, but happens when bugs occur.
        assert sys.exc_info() == (None, None, None), sys.exc_info()

        if ref_count1 == ref_count2:
            result = True
            break

        if explain and count == max_rounds - 1:
            snapObjRefCntMap(before=False)

    reenablePrinting()

    if result:
        print("PASSED")
    else:
        print("FAILED", ref_count1, ref_count2, "leaked", ref_count2 - ref_count1)

        if explain:
            print("REPORT of differences:")
            assert m1
            assert m2

            for key in m1:
                if key not in m2:
                    print("*" * 80)
                    print("extra:", m1[key], key)
                elif m1[key] != m2[key]:
                    print("*" * 80)
                    print(m1[key], "->", m2[key], key)
                else:
                    pass

            for key in m2:
                if key not in m1:
                    print("*" * 80)
                    print("missing:", m2[key], key)

                    # print m1[key]

    assert sys.exc_info() == (None, None, None), sys.exc_info()

    gc.collect()
    sys.stdout.flush()

    return result


def createSearchMode():
    # Dealing with many options, pylint: disable=too-many-branches

    parser = OptionParser()

    select_group = OptionGroup(parser, "Select Tests")

    select_group.add_option(
        "--pattern",
        action="store",
        dest="pattern",
        default="",
        help="""\
Execute only tests matching the pattern. Defaults to all tests.""",
    )
    select_group.add_option(
        "--all",
        action="store_true",
        dest="all",
        default=False,
        help="""\
Execute all tests, continue execution even after failure of one.""",
    )

    parser.add_option_group(select_group)

    debug_group = OptionGroup(parser, "Test features")

    debug_group.add_option(
        "--debug",
        action="store_true",
        dest="debug",
        default=False,
        help="""\
Executing all self checks possible to find errors in Nuitka, good for test coverage.
Defaults to off.""",
    )

    debug_group.add_option(
        "--commands",
        action="store_true",
        dest="show_commands",
        default=False,
        help="""Output commands being done in output comparison.
Defaults to off.""",
    )

    parser.add_option_group(debug_group)

    options, positional_args = parser.parse_args()

    if options.debug:
        addExtendedExtraOptions("--debug")

    if options.show_commands:
        os.environ["NUITKA_TRACE_COMMANDS"] = "1"

    # Default to searching.
    mode = positional_args[0] if positional_args else "search"

    # Avoid having to use options style.
    if mode in ("search", "only"):
        if len(positional_args) >= 2 and not options.pattern:
            options.pattern = positional_args[1]

    if mode == "search":
        if options.all:
            return SearchModeAll()
        elif options.pattern:
            pattern = options.pattern.replace("/", os.path.sep)
            return SearchModeByPattern(pattern)
        else:
            return SearchModeImmediate()
    elif mode == "resume":
        return SearchModeResume(sys.modules["__main__"].__file__)
    elif mode == "only":
        if options.pattern:
            pattern = options.pattern.replace("/", os.path.sep)
            return SearchModeOnly(pattern)
        else:
            assert False
    elif mode == "coverage":
        return SearchModeCoverage()
    else:
        assert False


def reportSkip(reason, dirname, filename):
    case = os.path.join(dirname, filename)
    case = os.path.normpath(case)

    my_print("Skipped, %s (%s)." % (case, reason))


def executeReferenceChecked(prefix, names, tests_skipped, tests_stderr, explain=False):
    gc.disable()

    extract_number = lambda name: int(name.replace(prefix, ""))

    # Find the function names.
    matching_names = tuple(
        name for name in names if name.startswith(prefix) and name[-1].isdigit()
    )

    old_stderr = sys.stderr

    # Everything passed
    result = True

    for name in sorted(matching_names, key=extract_number):
        number = extract_number(name)

        # print(tests_skipped)
        if number in tests_skipped:
            my_print(name + ": SKIPPED (%s)" % tests_skipped[number])
            continue

        # Avoid unraisable output.
        try:
            if number in tests_stderr:
                sys.stderr = open(os.devnull, "wb")
        except OSError:  # Windows
            if not checkReferenceCount(names[name], explain=explain):
                result = False
        else:
            if not checkReferenceCount(names[name], explain=explain):
                result = False

            if number in tests_stderr:
                new_stderr = sys.stderr
                sys.stderr = old_stderr
                new_stderr.close()

    gc.enable()
    return result


def addToPythonPath(python_path, in_front=False):
    if type(python_path) in (tuple, list):
        python_path = os.pathsep.join(python_path)

    if python_path:
        if "PYTHONPATH" in os.environ:
            if in_front:
                os.environ["PYTHONPATH"] = (
                    python_path + os.pathsep + os.environ["PYTHONPATH"]
                )
            else:
                os.environ["PYTHONPATH"] += os.pathsep + python_path
        else:
            os.environ["PYTHONPATH"] = python_path


@contextmanager
def withPythonPathChange(python_path):
    if python_path:
        if type(python_path) not in (tuple, list):
            python_path = python_path.split(os.pathsep)

        python_path = [
            os.path.normpath(os.path.abspath(element)) for element in python_path
        ]

        python_path = os.pathsep.join(python_path)

        if "PYTHONPATH" in os.environ:
            old_path = os.environ["PYTHONPATH"]
            os.environ["PYTHONPATH"] += os.pathsep + python_path
        else:
            old_path = None
            os.environ["PYTHONPATH"] = python_path

    #     print(
    #         "Effective PYTHONPATH in %s is %r" % (
    #             sys.modules["__main__"],
    #             os.environ.get("PYTHONPATH", "")
    #         )
    #     )

    yield

    if python_path:
        if old_path is None:
            del os.environ["PYTHONPATH"]
        else:
            os.environ["PYTHONPATH"] = old_path


def addExtendedExtraOptions(*args):
    old_value = os.environ.get("NUITKA_EXTRA_OPTIONS")

    value = old_value

    for arg in args:
        if value is None:
            value = arg
        else:
            value += " " + arg

    os.environ["NUITKA_EXTRA_OPTIONS"] = value

    return old_value


@contextmanager
def withExtendedExtraOptions(*args):
    assert args

    old_value = addExtendedExtraOptions(*args)

    yield

    if old_value is None:
        del os.environ["NUITKA_EXTRA_OPTIONS"]
    else:
        os.environ["NUITKA_EXTRA_OPTIONS"] = old_value


def indentedCode(codes, count):
    """Indent code, used for generating test codes."""
    return "\n".join(" " * count + line if line else "" for line in codes)


def convertToPython(doctests, line_filter=None):
    """Convert give doctest string to static Python code."""
    # This is convoluted, but it just needs to work, pylint: disable=too-many-branches

    import doctest

    code = doctest.script_from_examples(doctests)

    if code.endswith("\n"):
        code += "#\n"
    else:
        assert False

    output = []
    inside = False

    def getPrintPrefixed(evaluated, line_number):
        try:
            node = ast.parse(evaluated.lstrip(), "eval")
        except SyntaxError:
            return evaluated

        if node.body[0].__class__.__name__ == "Expr":
            count = 0

            while evaluated.startswith(" " * count):
                count += 1

            if sys.version_info < (3,):
                modified = (count - 1) * " " + "print " + evaluated
                return (
                    (count - 1) * " "
                    + ("print 'Line %d'" % line_number)
                    + "\n"
                    + modified
                )
            else:
                modified = (count - 1) * " " + "print(" + evaluated + "\n)\n"
                return (
                    (count - 1) * " "
                    + ("print('Line %d'" % line_number)
                    + ")\n"
                    + modified
                )
        else:
            return evaluated

    def getTried(evaluated, line_number):
        if sys.version_info < (3,):
            return """
try:
%(evaluated)s
except Exception as __e:
    print "Occurred", type(__e), __e
""" % {
                "evaluated": indentedCode(
                    getPrintPrefixed(evaluated, line_number).split("\n"), 4
                )
            }
        else:
            return """
try:
%(evaluated)s
except Exception as __e:
    print("Occurred", type(__e), __e)
""" % {
                "evaluated": indentedCode(
                    getPrintPrefixed(evaluated, line_number).split("\n"), 4
                )
            }

    def isOpener(evaluated):
        evaluated = evaluated.lstrip()

        if evaluated == "":
            return False

        return evaluated.split()[0] in (
            "def",
            "with",
            "class",
            "for",
            "while",
            "try:",
            "except",
            "except:",
            "finally:",
            "else:",
        )

    chunk = None
    for line_number, line in enumerate(code.split("\n")):
        # print "->", inside, line

        if line_filter is not None and line_filter(line):
            continue

        if inside and line and line[0].isalnum() and not isOpener(line):
            output.append(getTried("\n".join(chunk), line_number))

            chunk = []
            inside = False

        if inside and not (line.startswith("#") and line.find("SyntaxError:") != -1):
            chunk.append(line)
        elif line.startswith("#"):
            if line.find("SyntaxError:") != -1:
                # print "Syntax error detected"

                if inside:
                    # print "Dropping chunk", chunk

                    chunk = []
                    inside = False
                else:
                    del output[-1]
        elif isOpener(line):
            inside = True
            chunk = [line]
        elif line.strip() == "":
            output.append(line)
        else:
            output.append(getTried(line, line_number))

    return "\n".join(output).rstrip() + "\n"


def compileLibraryPath(search_mode, path, stage_dir, decide, action):
    my_print("Checking standard library path:", path)

    for root, dirnames, filenames in os.walk(path):
        dirnames_to_remove = [dirname for dirname in dirnames if "-" in dirname]

        for dirname in dirnames_to_remove:
            dirnames.remove(dirname)

        dirnames.sort()

        filenames = [filename for filename in filenames if decide(root, filename)]

        for filename in sorted(filenames):
            if not search_mode.consider(root, filename):
                continue

            full_path = os.path.join(root, filename)

            my_print(full_path, ":", end=" ")
            sys.stdout.flush()

            action(stage_dir, path, full_path)


def compileLibraryTest(search_mode, stage_dir, decide, action):
    if not os.path.exists(stage_dir):
        os.makedirs(stage_dir)

    my_dirname = os.path.join(os.path.dirname(__file__), "../../..")
    my_dirname = os.path.normpath(my_dirname)

    paths = [path for path in sys.path if not path.startswith(my_dirname)]

    my_print("Using standard library paths:")
    for path in paths:
        my_print(path)

    for path in paths:
        print("Checking path:", path)
        compileLibraryPath(
            search_mode=search_mode,
            path=path,
            stage_dir=stage_dir,
            decide=decide,
            action=action,
        )

    search_mode.finish()


def run_async(coro):
    """ Execute a coroutine until it's done. """

    values = []
    result = None
    while True:
        try:
            values.append(coro.send(None))
        except StopIteration as ex:
            result = ex.args[0] if ex.args else None
            break
    return values, result


def async_iterate(g):
    """ Execute async generator until it's done. """

    # Test code for Python3, catches all kinds of exceptions.
    # pylint: disable=broad-except

    # Also Python3 only, pylint: disable=I0021,undefined-variable

    res = []
    while True:
        try:
            g.__anext__().__next__()
        except StopAsyncIteration:
            res.append("STOP")
            break
        except StopIteration as ex:
            if ex.args:
                res.append("ex arg %s" % ex.args[0])
            else:
                res.append("EMPTY StopIteration")
                break
        except Exception as ex:
            res.append(str(type(ex)))

    return res


def getTestingCacheDir():
    cache_dir = getCacheDir()

    result = os.path.join(cache_dir, "tests_state")
    makePath(result)
    return result


def getTestingCPythonOutputsCacheDir():
    cache_dir = getCacheDir()

    result = os.path.join(
        cache_dir, "cpython_outputs", os.environ.get("NUITKA_TEST_SUITE", "")
    )

    makePath(result)
    return result


@contextmanager
def withDirectoryChange(path, allow_none=False):
    if path is not None or not allow_none:
        old_cwd = os.getcwd()
        os.chdir(path)

    yield

    if path is not None or not allow_none:
        os.chdir(old_cwd)


def scanDirectoryForTestCases(dirname, template_context=None):
    filenames = os.listdir(dirname)

    filenames = [
        filename
        for filename in filenames
        if (filename.endswith(".py") and not filename + ".j2" in filenames)
        or filename.endswith(".j2")
    ]

    # Jinja2 environment is optional.
    env = None

    for filename in sorted(filenames):
        if not decideFilenameVersionSkip(filename):
            continue

        if filename.endswith(".j2"):
            # Needs to be a dictionary with template arguments.
            assert template_context is not None

            if env is None:
                import jinja2

                env = jinja2.Environment(
                    loader=jinja2.FileSystemLoader("."),
                    trim_blocks=True,
                    lstrip_blocks=True,
                )
                env.undefined = jinja2.StrictUndefined

            template = env.get_template(filename)

            code = template.render(name=template.name, **template_context)

            filename = filename[:-3]
            with open(filename, "w") as output:
                output.write(
                    "'''Automatically generated test, not part of releases or git.\n\n'''\n"
                )

                output.write(code)

        yield filename


def setupCacheHashSalt(test_code_path):
    assert os.path.exists(test_code_path)

    if os.path.exists(os.path.join(test_code_path, ".git")):
        git_cmd = ["git", "ls-tree", "-r", "HEAD", test_code_path]

        process = subprocess.Popen(
            args=git_cmd,
            stdin=getNullInput(),
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )

        stdout_git, stderr_git = process.communicate()
        assert process.returncode == 0, stderr_git

        salt_value = hashlib.md5(stdout_git)
    else:
        salt_value = hashlib.md5()

        for filename in getFileList(test_code_path):
            if filename.endswith(".py"):
                salt_value.update(getFileContents(filename, mode="rb"))

    os.environ["NUITKA_HASH_SALT"] = salt_value.hexdigest()


def displayFolderContents(name, path):
    test_logger.info("Listing of %s %r:" % (name, path))

    if os.path.exists(path):
        if os.name == "nt":
            command = "dir /b /s /a:-D %s" % path
        else:
            command = "ls -Rla %s" % path

        os.system(command)
    else:
        test_logger.info("Does not exist.")


def displayFileContents(name, path):
    test_logger.info("Contents of %s %r:" % (name, path))

    if os.path.exists(path):
        for line in getFileContentByLine(path):
            my_print(line)
    else:
        test_logger.info("Does not exist.")


def someGenerator():
    yield 1
    yield 2
    yield 3


def someGeneratorRaising():
    yield 1
    raise TypeError(2)


# checks requirements needed to run each test module, according to the specified special comment
# special comments are in the following formats:
#     "# nuitka-skip-unless-expression: expression to be evaluated"
#       OR
#     "# nuitka-skip-unless-imports: module1,module2,..."
def checkRequirements(filename):
    for line in readSourceCodeFromFilename(None, filename).splitlines():
        if line.startswith("# nuitka-skip-unless-"):
            if line[21:33] == "expression: ":
                expression = line[33:]
                with open(os.devnull, "w") as devnull:
                    result = subprocess.call(
                        (
                            os.environ["PYTHON"],
                            "-c",
                            "import sys, os; sys.exit(not bool(%s))" % expression,
                        ),
                        stdout=devnull,
                        stderr=subprocess.STDOUT,
                    )
                if result != 0:
                    return (False, "Expression '%s' evaluated to false" % expression)

            elif line[21:30] == "imports: ":
                imports_needed = line[30:].rstrip().split(",")
                for i in imports_needed:
                    if not hasModule(i):
                        return (
                            False,
                            i
                            + " not installed for this Python version, but test needs it",
                        )
    # default return value
    return (True, "")


class DelayedExecutionThread(threading.Thread):
    def __init__(self, timeout, func):
        threading.Thread.__init__(self)
        self.timeout = timeout

        self.func = func

    def run(self):
        time.sleep(self.timeout)
        self.func()


def executeAfterTimePassed(timeout, func):
    alarm = DelayedExecutionThread(timeout=timeout, func=func)
    alarm.start()


def killProcess(name, pid):
    """Kill a process in a portable way.

    Right now SIGINT is used, unclear what to do on Windows
    with Python2 or non-related processes.
    """

    if str is bytes and os.name == "nt":
        test_logger.info("Using taskkill on test process %r." % name)
        os.system("taskkill.exe /PID %d" % pid)
    else:
        test_logger.info("Killing test process %r." % name)
        os.kill(pid, signal.SIGINT)
