# encoding: utf-8
#
# Copyright 2009-2017 Greg Neagle.
#
# 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
#
#      https://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.
"""
info.py

Created by Greg Neagle on 2016-12-14.

Utilities that retrieve information from the current machine.
"""
# standard libs
import ctypes
import ctypes.util
import fcntl
import os
import select
import struct
import subprocess
import sys

# Apple's libs
import LaunchServices
# PyLint cannot properly find names inside Cocoa libraries, so issues bogus
# No name 'Foo' in module 'Bar' warnings. Disable them.
# pylint: disable=E0611
from Foundation import NSMetadataQuery, NSPredicate, NSRunLoop
from Foundation import NSDate, NSTimeZone
# pylint: enable=E0611

# our libs
from . import display
from . import munkilog
from . import osutils
from . import pkgutils
from . import prefs
from . import reports
from . import utils
from . import FoundationPlist


# Always ignore these directories when discovering applications.
APP_DISCOVERY_EXCLUSION_DIRS = set([
    'Volumes', 'tmp', '.vol', '.Trashes', '.MobileBackups', '.Spotlight-V100',
    '.fseventsd', 'Network', 'net', 'home', 'cores', 'dev', 'private',
    ])


class Error(Exception):
    """Class for domain specific exceptions."""


class TimeoutError(Error):
    """Timeout limit exceeded since last I/O."""


def set_file_nonblock(fileobj, non_blocking=True):
    """Set non-blocking flag on a file object.

    Args:
      fileobj: file
      non_blocking: bool, default True, non-blocking mode or not
    """
    flags = fcntl.fcntl(fileobj.fileno(), fcntl.F_GETFL)
    if bool(flags & os.O_NONBLOCK) != non_blocking:
        flags ^= os.O_NONBLOCK
    fcntl.fcntl(fileobj.fileno(), fcntl.F_SETFL, flags)


class Popen(subprocess.Popen):
    """Subclass of subprocess.Popen to add support for timeouts."""

    def timed_readline(self, fileobj, timeout):
        """Perform readline-like operation with timeout.

        Args:
            fileobj: file object to .readline() on
            timeout: int, seconds of inactivity to raise error at
        Raises:
            TimeoutError, if timeout is reached
        """
        # pylint: disable=no-self-use
        set_file_nonblock(fileobj)

        output = []
        inactive = 0
        while 1:
            (rlist, dummy_wlist, dummy_xlist) = select.select(
                [fileobj], [], [], 1.0)

            if not rlist:
                inactive += 1  # approx -- py select doesn't return tv
                if inactive >= timeout:
                    break
            else:
                inactive = 0
                char = fileobj.read(1)
                output.append(char)  # keep newline
                if char == '' or char == '\n':
                    break

        set_file_nonblock(fileobj, non_blocking=False)

        if inactive >= timeout:
            raise TimeoutError  # note, an incomplete line can be lost
        else:
            return ''.join(output)

    def communicate(self, std_in=None, timeout=0):
        """Communicate, optionally ending after a timeout of no activity.

        Args:
            std_in: str, to send on stdin
            timeout: int, seconds of inactivity to raise error at
        Returns:
            (str or None, str or None) for stdout, stderr
        Raises:
            TimeoutError, if timeout is reached
        """
        # pylint: disable=arguments-differ
        if timeout <= 0:
            return super(Popen, self).communicate(input=std_in)

        fds = []
        stdout = []
        stderr = []

        if self.stdout is not None:
            set_file_nonblock(self.stdout)
            fds.append(self.stdout)
        if self.stderr is not None:
            set_file_nonblock(self.stderr)
            fds.append(self.stderr)

        if std_in is not None and sys.stdin is not None:
            sys.stdin.write(std_in)

        returncode = None
        inactive = 0
        while returncode is None:
            (rlist, dummy_wlist, dummy_xlist) = select.select(
                fds, [], [], 1.0)

            if not rlist:
                inactive += 1
                if inactive >= timeout:
                    raise TimeoutError
            else:
                inactive = 0
                for filedescr in rlist:
                    if filedescr is self.stdout:
                        stdout.append(filedescr.read())
                    elif filedescr is self.stderr:
                        stderr.append(filedescr.read())

            returncode = self.poll()

        if self.stdout is not None:
            stdout_str = ''.join(stdout)
        else:
            stdout_str = None
        if self.stderr is not None:
            stderr_str = ''.join(stderr)
        else:
            stderr_str = None

        return (stdout_str, stderr_str)


def _unsigned(i):
    """Translate a signed int into an unsigned int.  Int type returned
    is longer than the original since Python has no unsigned int."""
    return i & 0xFFFFFFFF


def _asciiz_to_str(a_string):
    """Transform a null-terminated string of any length into a Python str.
    Returns a normal Python str that has been terminated.
    """
    i = a_string.find('\0')
    if i > -1:
        a_string = a_string[0:i]
    return a_string


def _f_flags_to_set(f_flags):
    """Transform an int f_flags parameter into a set of mount options.
    Returns a set.
    """
    # see /usr/include/sys/mount.h for the bitmask constants.
    flags = set()
    if f_flags & 0x1:
        flags.add('read-only')
    if f_flags & 0x1000:
        flags.add('local')
    if f_flags & 0x4000:
        flags.add('rootfs')
    if f_flags & 0x4000000:
        flags.add('automounted')
    return flags


def get_filesystems():
    """Get a list of all mounted filesystems on this system.

    Return value is dict, e.g. {
        int st_dev: {
            'f_fstypename': 'nfs',
            'f_mntonname': '/mountedpath',
            'f_mntfromname': 'homenfs:/path',
        },
    }

    Note: st_dev values are static for potentially only one boot, but
    static for multiple mount instances.
    """

    mnt_nowait = 2

    libc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("c"))
    # see man GETFSSTAT(2) for struct
    statfs_32_struct = '=hh ll ll ll lQ lh hl 2l 15s 90s 90s x 16x'
    statfs_64_struct = '=Ll QQ QQ Q ll l LLL 16s 1024s 1024s 32x'
    os_version = osutils.getOsVersion(as_tuple=True)
    if os_version <= (10, 5):
        mode = 32
    else:
        mode = 64

    if mode == 64:
        statfs_struct = statfs_64_struct
    else:
        statfs_struct = statfs_32_struct

    sizeof_statfs_struct = struct.calcsize(statfs_struct)
    bufsize = 30 * sizeof_statfs_struct  # only supports 30 mounted fs
    buf = ctypes.create_string_buffer(bufsize)

    if mode == 64:
        # some 10.6 boxes return 64-bit structures on getfsstat(), some do not.
        # forcefully call the 64-bit version in cases where we think
        # a 64-bit struct will be returned.
        no_of_structs = libc.getfsstat64(ctypes.byref(buf), bufsize, mnt_nowait)
    else:
        no_of_structs = libc.getfsstat(ctypes.byref(buf), bufsize, mnt_nowait)

    if no_of_structs < 0:
        display.display_debug1('getfsstat() returned errno %d' % no_of_structs)
        return {}

    ofs = 0
    output = {}
    # struct_unpack returns lots of values, but we use only a few
    # pylint: disable=unused-variable
    for i in xrange(0, no_of_structs):
        if mode == 64:
            (f_bsize, f_iosize, f_blocks, f_bfree, f_bavail, f_files,
             f_ffree, f_fsid_0, f_fsid_1, f_owner, f_type, f_flags,
             f_fssubtype,
             f_fstypename, f_mntonname, f_mntfromname) = struct.unpack(
                 statfs_struct, str(buf[ofs:ofs+sizeof_statfs_struct]))
        elif mode == 32:
            (f_otype, f_oflags, f_bsize, f_iosize, f_blocks, f_bfree, f_bavail,
             f_files, f_ffree, f_fsid, f_owner, f_reserved1, f_type, f_flags,
             f_reserved2_0, f_reserved2_1, f_fstypename, f_mntonname,
             f_mntfromname) = struct.unpack(
                 statfs_struct, str(buf[ofs:ofs+sizeof_statfs_struct]))

        try:
            stat_val = os.stat(_asciiz_to_str(f_mntonname))
            output[stat_val.st_dev] = {
                'f_flags_set': _f_flags_to_set(f_flags),
                'f_fstypename': _asciiz_to_str(f_fstypename),
                'f_mntonname': _asciiz_to_str(f_mntonname),
                'f_mntfromname': _asciiz_to_str(f_mntfromname),
            }
        except OSError:
            pass

        ofs += sizeof_statfs_struct
    # pylint: enable=unused-variable

    return output


FILESYSTEMS = {}
def is_excluded_filesystem(path, _retry=False):
    """Gets filesystem information for a path and determine if it should be
    excluded from application searches.

    Returns True if path is located on NFS, is read only, or
    is not marked local.
    Returns False if none of these conditions are true.
    Returns None if it cannot be determined.
    """
    global FILESYSTEMS

    if not path:
        return None

    path_components = path.split('/')
    if len(path_components) > 1:
        if path_components[1] in APP_DISCOVERY_EXCLUSION_DIRS:
            return True

    if not FILESYSTEMS or _retry:
        FILESYSTEMS = get_filesystems()

    try:
        stat_val = os.stat(path)
    except OSError:
        stat_val = None

    if stat_val is None or stat_val.st_dev not in FILESYSTEMS:
        if not _retry:
            # perhaps the stat() on the path caused autofs to mount
            # the required filesystem and now it will be available.
            # try one more time to look for it after flushing the cache.
            display.display_debug1(
                'Trying isExcludedFilesystem again for %s' % path)
            return is_excluded_filesystem(path, True)
        else:
            display.display_debug1(
                'Could not match path %s to a filesystem' % path)
            return None

    exc_flags = ('read-only' in FILESYSTEMS[stat_val.st_dev]['f_flags_set'] or
                 'local' not in FILESYSTEMS[stat_val.st_dev]['f_flags_set'])
    is_nfs = FILESYSTEMS[stat_val.st_dev]['f_fstypename'] == 'nfs'

    if is_nfs or exc_flags:
        display.display_debug1(
            'Excluding %s (flags %s, nfs %s)' % (path, exc_flags, is_nfs))

    return is_nfs or exc_flags


def find_apps_in_dirs(dirlist):
    """Do spotlight search for type applications within the
    list of directories provided. Returns a list of paths to applications
    these appear to always be some form of unicode string.
    """
    applist = []
    query = NSMetadataQuery.alloc().init()
    query.setPredicate_(
        NSPredicate.predicateWithFormat_('(kMDItemKind = "Application")'))
    query.setSearchScopes_(dirlist)
    query.startQuery()
    # Spotlight isGathering phase - this is the initial search. After the
    # isGathering phase Spotlight keeps running returning live results from
    # filesystem changes, we are not interested in that phase.
    # Run for 0.3 seconds then check if isGathering has completed.
    runtime = 0
    maxruntime = 20
    while query.isGathering() and runtime <= maxruntime:
        runtime += 0.3
        NSRunLoop.currentRunLoop(
            ).runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.3))
    query.stopQuery()

    if runtime >= maxruntime:
        display.display_warning(
            'Spotlight search for applications terminated due to excessive '
            'time. Possible causes: Spotlight indexing is turned off for a '
            'volume; Spotlight is reindexing a volume.')

    for item in query.results():
        pathname = item.valueForAttribute_('kMDItemPath')
        if pathname and not is_excluded_filesystem(pathname):
            applist.append(pathname)

    return applist


def spotlight_installed_apps():
    """Get paths of currently installed applications per Spotlight.
    Return value is list of paths.
    Excludes most non-boot volumes.
    In future may include local r/w volumes.
    """
    dirlist = []
    applist = []

    for filename in osutils.listdir(u'/'):
        pathname = os.path.join(u'/', filename)
        if (os.path.isdir(pathname) and not os.path.islink(pathname) and
                not is_excluded_filesystem(pathname)):
            if filename.endswith('.app'):
                applist.append(pathname)
            else:
                dirlist.append(pathname)

    # Future code changes may mean we wish to look for Applications
    # installed on any r/w local volume.
    #for f in osutils.listdir(u'/Volumes'):
    #    p = os.path.join(u'/Volumes', f)
    #    if os.path.isdir(p) and not os.path.islink(p) \
    #                        and not is_excluded_filesystem(p):
    #        dirlist.append(p)

    # /Users is not currently excluded, so no need to add /Users/Shared.
    #dirlist.append(u'/Users/Shared')

    applist.extend(find_apps_in_dirs(dirlist))
    return applist


def launchservices_installed_apps():
    """Get paths of currently installed applications per LaunchServices.
    Return value is list of paths.
    Ignores apps installed on other volumes
    """
    # PyLint cannot properly find names inside Cocoa libraries, so issues bogus
    # "Module 'Foo' has no 'Bar' member" warnings. Disable them.
    # pylint: disable=E1101
    # we access a "protected" function from LaunchServices
    # pylint: disable=W0212

    apps = LaunchServices._LSCopyAllApplicationURLs(None)
    applist = []
    for app in apps:
        app_path = app.path()
        if (app_path and not is_excluded_filesystem(app_path) and
                os.path.exists(app_path)):
            applist.append(app_path)

    return applist


@utils.Memoize
def sp_application_data():
    '''Uses system profiler to get application info for this machine'''
    cmd = ['/usr/sbin/system_profiler', 'SPApplicationsDataType', '-xml']
    # uses our internal Popen instead of subprocess's so we can timeout
    proc = Popen(cmd, shell=False, bufsize=-1,
                 stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                 stderr=subprocess.PIPE)
    try:
        output, dummy_error = proc.communicate(timeout=60)
    except TimeoutError:
        display.display_error(
            'system_profiler hung; skipping SPApplicationsDataType query')
        # return empty dict
        return {}
    try:
        plist = FoundationPlist.readPlistFromString(output)
        # system_profiler xml is an array
        application_data = {}
        for item in plist[0]['_items']:
            application_data[item.get('path')] = item
    except BaseException:
        application_data = {}
    return application_data


@utils.Memoize
def app_data():
    """Gets info on currently installed apps.
    Returns a list of dicts containing path, name, version and bundleid"""
    application_data = []
    display.display_debug1(
        'Getting info on currently installed applications...')
    applist = set(launchservices_installed_apps())
    applist.update(spotlight_installed_apps())
    for pathname in applist:
        iteminfo = {}
        iteminfo['name'] = os.path.splitext(os.path.basename(pathname))[0]
        iteminfo['path'] = pathname
        plistpath = os.path.join(pathname, 'Contents', 'Info.plist')
        if os.path.exists(plistpath):
            try:
                plist = FoundationPlist.readPlist(plistpath)
                iteminfo['bundleid'] = plist.get('CFBundleIdentifier', '')
                if 'CFBundleName' in plist:
                    iteminfo['name'] = plist['CFBundleName']
                iteminfo['version'] = pkgutils.getBundleVersion(pathname)
                application_data.append(iteminfo)
            except BaseException:
                pass
        else:
            # possibly a non-bundle app. Use system_profiler data
            # to get app name and version
            sp_app_data = sp_application_data()
            if pathname in sp_app_data:
                item = sp_app_data[pathname]
                iteminfo['bundleid'] = ''
                iteminfo['version'] = item.get('version') or '0.0.0.0.0'
                if item.get('_name'):
                    iteminfo['name'] = item['_name']
                application_data.append(iteminfo)
    return application_data


@utils.Memoize
def filtered_app_data():
    '''Returns a filtered version of app_data, filtering out apps in user
    home directories for use by compare.compare_application_version()'''
    return [item for item in app_data()
            if not (item['path'].startswith('/Users/') and
                    not item['path'].startswith('/Users/Shared/'))]


@utils.Memoize
def get_version():
    """Returns version of munkitools, reading version.plist"""
    vers = "UNKNOWN"
    build = ""
    # find the munkilib directory, and the version file
    munkilibdir = os.path.dirname(os.path.abspath(__file__))
    versionfile = os.path.join(munkilibdir, "version.plist")
    if os.path.exists(versionfile):
        try:
            vers_plist = FoundationPlist.readPlist(versionfile)
        except FoundationPlist.NSPropertyListSerializationException:
            pass
        else:
            try:
                vers = vers_plist['CFBundleShortVersionString']
                build = vers_plist['BuildNumber']
            except KeyError:
                pass
    if build:
        vers = vers + "." + build
    return vers


def get_hardware_info():
    '''Uses system profiler to get hardware info for this machine'''
    cmd = ['/usr/sbin/system_profiler', 'SPHardwareDataType', '-xml']
    proc = subprocess.Popen(cmd, shell=False, bufsize=-1,
                            stdin=subprocess.PIPE,
                            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    (output, dummy_error) = proc.communicate()
    try:
        plist = FoundationPlist.readPlistFromString(output)
        # system_profiler xml is an array
        sp_dict = plist[0]
        items = sp_dict['_items']
        sp_hardware_dict = items[0]
        return sp_hardware_dict
    except BaseException:
        return {}


def get_ip_addresses(kind):
    '''Uses system profiler to get active IP addresses for this machine
    kind must be one of 'IPv4' or 'IPv6' '''
    ip_addresses = []
    cmd = ['/usr/sbin/system_profiler', 'SPNetworkDataType', '-xml']
    proc = subprocess.Popen(cmd, shell=False, bufsize=-1,
                            stdin=subprocess.PIPE,
                            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    (output, dummy_error) = proc.communicate()
    try:
        plist = FoundationPlist.readPlistFromString(output)
        # system_profiler xml is an array of length 1
        sp_dict = plist[0]
        items = sp_dict['_items']
    except BaseException:
        # something is wrong with system_profiler output
        # so bail
        return ip_addresses

    for item in items:
        try:
            ip_addresses.extend(item[kind]['Addresses'])
        except KeyError:
            # 'IPv4", 'IPv6' or 'Addresses' is empty, so we ignore
            # this item
            pass
    return ip_addresses


def has_intel64support():
    """Does this machine support 64-bit Intel instruction set?"""
    libc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("c"))

    size = ctypes.c_size_t()
    buf = ctypes.c_int()
    size.value = ctypes.sizeof(buf)

    libc.sysctlbyname(
        "hw.optional.x86_64", ctypes.byref(buf), ctypes.byref(size), None, 0)

    return buf.value == 1


def available_disk_space(volumepath='/'):
    """Returns available diskspace in KBytes.

    Args:
      volumepath: str, optional, default '/'
    Returns:
      int, KBytes in free space available
    """
    if volumepath is None:
        volumepath = '/'
    try:
        stat_val = os.statvfs(volumepath)
    except OSError, err:
        display.display_error(
            'Error getting disk space in %s: %s', volumepath, str(err))
        return 0
     # f_bavail matches df(1) output
    return int(stat_val.f_frsize * stat_val.f_bavail / 1024)


@utils.Memoize
def getMachineFacts():
    """Gets some facts about this machine we use to determine if a given
    installer is applicable to this OS or hardware"""
    # pylint: disable=C0103
    machine = dict()
    machine['hostname'] = os.uname()[1]
    machine['arch'] = os.uname()[4]
    machine['os_vers'] = osutils.getOsVersion(only_major_minor=False)
    hardware_info = get_hardware_info()
    machine['machine_model'] = hardware_info.get('machine_model', 'UNKNOWN')
    machine['munki_version'] = get_version()
    machine['ipv4_address'] = get_ip_addresses('IPv4')
    machine['ipv6_address'] = get_ip_addresses('IPv6')
    machine['serial_number'] = hardware_info.get('serial_number', 'UNKNOWN')

    if machine['arch'] == 'x86_64':
        machine['x86_64_capable'] = True
    elif machine['arch'] == 'i386':
        machine['x86_64_capable'] = has_intel64support()
    return machine


def valid_plist(path):
    """Uses plutil to determine if path contains a valid plist.
    Returns True or False."""
    retcode = subprocess.call(['/usr/bin/plutil', '-lint', '-s', path])
    return retcode == 0


@utils.Memoize
def get_conditions():
    """Fetches key/value pairs from condition scripts
    which can be placed into /usr/local/munki/conditions"""
    # define path to conditions directory which would contain
    # admin created scripts
    scriptdir = os.path.realpath(os.path.dirname(sys.argv[0]))
    conditionalscriptdir = os.path.join(scriptdir, "conditions")
    # define path to ConditionalItems.plist
    conditionalitemspath = os.path.join(
        prefs.pref('ManagedInstallDir'), 'ConditionalItems.plist')
    try:
        # delete CondtionalItems.plist so that we're starting fresh
        os.unlink(conditionalitemspath)
    except (OSError, IOError):
        pass
    if os.path.exists(conditionalscriptdir):
        for conditionalscript in osutils.listdir(conditionalscriptdir):
            if conditionalscript.startswith('.'):
                # skip files that start with a period
                continue
            conditionalscriptpath = os.path.join(
                conditionalscriptdir, conditionalscript)
            if os.path.isdir(conditionalscriptpath):
                # skip directories in conditions directory
                continue
            try:
                # attempt to execute condition script
                dummy_result, dummy_stdout, dummy_stderr = (
                    utils.runExternalScript(conditionalscriptpath))
            except utils.ScriptNotFoundError:
                pass  # script is not required, so pass
            except utils.RunExternalScriptError, err:
                print >> sys.stderr, unicode(err)
    else:
        # /usr/local/munki/conditions does not exist
        pass
    if (os.path.exists(conditionalitemspath) and
            valid_plist(conditionalitemspath)):
        # import conditions into conditions dict
        conditions = FoundationPlist.readPlist(conditionalitemspath)
        os.unlink(conditionalitemspath)
    else:
        # either ConditionalItems.plist does not exist
        # or does not pass validation
        conditions = {}
    return conditions


def saveappdata():
    """Save installed application data"""
    # data from app_data() is meant for use by updatecheck
    # we need to massage it a bit for more general usage
    munkilog.log('Saving application inventory...')
    app_inventory = []
    for item in app_data():
        inventory_item = {}
        inventory_item['CFBundleName'] = item.get('name')
        inventory_item['bundleid'] = item.get('bundleid')
        inventory_item['version'] = item.get('version')
        inventory_item['path'] = item.get('path', '')
        # use last path item (minus '.app' if present) as name
        inventory_item['name'] = \
            os.path.splitext(os.path.basename(inventory_item['path']))[0]
        app_inventory.append(inventory_item)
    try:
        FoundationPlist.writePlist(
            app_inventory,
            os.path.join(
                prefs.pref('ManagedInstallDir'), 'ApplicationInventory.plist'))
    except FoundationPlist.NSPropertyListSerializationException, err:
        display.display_warning(
            'Unable to save inventory report: %s' % err)


# conditional/predicate info functions
def subtract_tzoffset_from_date(the_date):
    """Input: NSDate object
    Output: NSDate object with same date and time as the UTC.
    In Los Angeles (PDT), '2011-06-20T12:00:00Z' becomes
    '2011-06-20 12:00:00 -0700'.
    In New York (EDT), it becomes '2011-06-20 12:00:00 -0400'.
    This allows a pkginfo item to reference a time in UTC that
    gets translated to the same relative local time.
    A force_install_after_date for '2011-06-20T12:00:00Z' will happen
    after 2011-06-20 12:00:00 local time.
    """
    # find our time zone offset in seconds
    timezone = NSTimeZone.defaultTimeZone()
    seconds_offset = timezone.secondsFromGMTForDate_(the_date)
    # return new NSDate minus local_offset
    return NSDate.alloc(
        ).initWithTimeInterval_sinceDate_(-seconds_offset, the_date)


def add_tzoffset_to_date(the_date):
    """Input: NSDate object
    Output: NSDate object with timezone difference added
    to the date. This allows conditional_item conditions to
    be written like so:

    <Key>condition</key>
    <string>date > CAST("2012-12-17T16:00:00Z", "NSDate")</string>

    with the intent being that the comparision is against local time.

    """
    # find our time zone offset in seconds
    timezone = NSTimeZone.defaultTimeZone()
    seconds_offset = timezone.secondsFromGMTForDate_(the_date)
    # return new NSDate minus local_offset
    return NSDate.alloc(
        ).initWithTimeInterval_sinceDate_(seconds_offset, the_date)


@utils.Memoize
def predicate_info_object():
    '''Returns our info object used for predicate comparisons'''
    info_object = {}
    machine = getMachineFacts()
    info_object.update(machine)
    info_object.update(get_conditions())
    # use our start time for "current" date (if we have it)
    # and add the timezone offset to it so we can compare
    # UTC dates as though they were local dates.
    info_object['date'] = add_tzoffset_to_date(
        NSDate.dateWithString_(
            reports.report.get('StartTime', reports.format_time())))
    os_vers = machine['os_vers']
    os_vers = os_vers + '.0.0'
    info_object['os_vers_major'] = int(os_vers.split('.')[0])
    info_object['os_vers_minor'] = int(os_vers.split('.')[1])
    info_object['os_vers_patch'] = int(os_vers.split('.')[2])
    if 'Book' in machine.get('machine_model', ''):
        info_object['machine_type'] = 'laptop'
    else:
        info_object['machine_type'] = 'desktop'
    return info_object


def predicate_evaluates_as_true(predicate_string, additional_info=None):
    '''Evaluates predicate against our info object'''
    display.display_debug1('Evaluating predicate: %s', predicate_string)
    info_object = predicate_info_object()
    if isinstance(additional_info, dict):
        info_object.update(additional_info)
    try:
        predicate = NSPredicate.predicateWithFormat_(predicate_string)
    except BaseException, err:
        display.display_warning('%s', err)
        # can't parse predicate, so return False
        return False

    result = predicate.evaluateWithObject_(info_object)
    display.display_debug1('Predicate %s is %s', predicate_string, result)
    return result


if __name__ == '__main__':
    print 'This is a library of support tools for the Munki Suite.'
