Source code for schrodinger.project.project

"""
Interface to the active Maestro Project.

Projects may be accessed while within
Maestro or without Maestro from a python script that
imports this module. Usually when running from within Maestro
the Project object would be returned via maestro.project_table_get()

For a description of basic concepts that clarify the relationship between the
Workspace and Maestro Projects as well as when and how changes in one affect
the other, please see the Basic Concepts section in the Python Module Overview
located at schrodinger.com/pythonapi.

Adding a copy of a structure to a project can be done using ::

    pt = maestro.project_table_get()
    pt.importStructure(ct)

All row access is with respect to the order of
rows in the project (which can differ from the order of rows
in Maestro's Project Table since the table's rows can be sorted
and rearranged).

Column and Row indices start at 1.

There are 3 ways to refer to rows:

1. Internal project index in mmproj, they are effectively indices into an array.
   These values are hidden from the user; avoid using them in scripts.
2. Project Table row number, as it appears in the left-most column of the PT in
   Maestro. This number can change when rows are deleted or the table is sorted.
3. Entry ID, which is unique for each row and never changes. These values are
   ints, but are often represented as strings, e.g. "1". Scratch entries will
   have non-int entry IDs, but are not stored in the project table.
   These are used in Maestro commands, ASLs, etc, and are visible to the user.

Iterating over all rows in the project is done using an expression of the form::

    pt=maestro.project_table_get()
    for row in pt.all_rows:
        # Now do something with row

Iterating over selected rows in the project is done using an expression of
the form::

    pt=maestro.project_table_get()
    for row in pt.selected_rows:
        # Now do something with row

Accessing each row of the project table returns a ProjectRow object. Common
operations on ProjectRow objects are to get (or set) a structure associated
with that row::

    st = row.getStructure()
    # and then update
    row.setStructure(st)

Also common is accessing the project data. This can be done either by the
name of the project table column or the dataname (the latter being as it
appears in Maestro format files)::

    energy = row.property['r_mmod_Relative_Potential_Energy-MMFF94']

New columns and new properties for existing entries can be added to the
project in a similar way.  Note that the refreshTable() function is usually
required to be called to show the results of additions to the project table.

A project should be open by at most one process at any given time.
It is an error to open a project currently opened by another process.

Copyright Schrodinger LLC, All rights reserved.

"""

from collections.abc import MutableMapping
import os
import re
import shutil
import tempfile
import time
from contextlib import contextmanager

from schrodinger.infra import mmbitset
from schrodinger import get_maestro
from schrodinger import structure
from schrodinger.infra import mm
from schrodinger.infra import mmobject
from schrodinger.infra import mmproj
from schrodinger.infra import mmsurf
from schrodinger.infra import projectmodel
from schrodinger.project import surface as project_surface
from schrodinger.structutils import analyze
from schrodinger.ui import maestro_ui

ASCENDING = 1
DESCENDING = -1

# EV 52287 Windows: Property Merge dumps error messages to terminal
# EV 51784 gen_smiles.py and gen_smarts.py displaying python errors (NameError)
# These all were due to using incorrect module hierarchy to invoke
# project_table_update().  The invocation must match the import

maestro = get_maestro()
_in_maestro = bool(maestro)

# Try to import the maestro module.  If it fails, simply assume that
# this module is being used outside of Maestro.  If it succeeds, then there
# are additional actions we perform for certain project module tasks (mostly
# to make sure that the user can see the results).
_module_name = "project"

INVALID_PROJECT_HANDLE = -1

# Constants for select actions
ADD, REPLACE, INVERT, ALL, NONE = list(range(5))

# Constants for ProjectRow.in_workspace:
NOT_IN_WORKSPACE, IN_WORKSPACE, LOCKED_IN_WORKSPACE = list(range(3))

###########################################################################
# Options that can be enabled or disabled on a per Project instance basis
# Use the enable/disable method in the Project class.
###########################################################################
AUTOMATIC_CACHE_FREEING = list(range(1))


class EmptyIterator:
    """
    Empty iterator.  Useful if you have some object that returns
    an iterator in most cases but in some cases does not.  In the "not"
    case you can return an empty iterator and the calling code can still use
    a  "for i in iterator" form as it will be a no-op.
    """

    def __init__(self):
        pass

    def __len__(self):
        """
        Needed to allow "if empty_iterator" to evaluate to False.
        Halts the iteration.  Can also be used to check to see
        if the iterator exists or not.

        """

        return 0

    def __iter__(self):
        """
        Make it possible to iterate without first having
        to check to see if there is an iterator on which to iterate.

        """

        return self

    def __next__(self):
        """
        We never have anything to iterate over.  So simply stop iterating.

        """

        raise StopIteration()


class _ProjectRowProperty(MutableMapping):
    """
    Dictionary-like container of entry properties. These can be accessed

    via the property name as it appears in the maestro file.

    Property names must be m2io data names, which are in the format
    '<type>_<author>_<property_name>', where '<type>' is a data type prefix,
    '<author>' is a source specification, and '<property_name>' is the
    actual name of the data.

    The data type prefix can specified as 's' for string, 'i' for integer,
    'r' for real and 'b' for boolean. The author specification should be
    'user' for user created properties. The property name can have embedded
    underscores.
    """

    def __init__(self, row):
        """
        Create an instance of the entry property container.

        :param row: Entry row.
        :type row: `ProjectRow`
        """
        self._project_handle = row._project_handle
        self._entry_index = row._entry_index
        self._row = row

    def _getPropertyType(self, prop):
        """
        Given a property (data name), return the property type.

        :raises KeyError if property does not exist.
        :raises ValueError if property "user" name is passed in.
        """
        try:
            i = mmproj.mmproj_project_get_property_index(
                self._project_handle, prop)
        except mm.MmException:
            raise KeyError("Could not find property '%s'" % prop)

        # Since mmproj_project_get_property_index() will happily accept
        # "user" names, check for them:
        data_name = mmproj.mmproj_property_get_data_name(
            self._project_handle, i)
        if data_name != prop:
            raise ValueError(
                "User property names not supported as keys: '%s'" % prop)

        # Get column's property type
        try:
            data_type = \
                mmproj.mmproj_property_get_data_type(self._project_handle,
                                                     i)
        except mm.MmException:
            raise RuntimeError("Could not get type for property '%s'" % prop)

        return data_type

    def __getitem__(self, prop):
        """
        Return the given property value.

        :param prop: Key object for property dict, must be a string starting
            with s, r, i, or b.
        :type prop: str

        :raises KeyError: If the property is missing.
        """

        # Will raise KeyError if property is not present:
        data_type = self._getPropertyType(str(prop))

        # Get the value of the correct type.  If we cannot, raise KeyError
        try:
            if data_type == mm.M2IO_REAL_TYPE:
                return mmproj.mmproj_index_entry_get_real_data(
                    self._project_handle, self._entry_index, prop)

            elif data_type == mm.M2IO_BOOLEAN_TYPE:
                val = \
                    mmproj.mmproj_index_entry_get_boolean_data(self._project_handle,
                                                               self._entry_index,
                                                               prop)
                return bool(val)  # return True or False

            elif data_type == mm.M2IO_INT_TYPE:
                return mmproj.mmproj_index_entry_get_integer_data(
                    self._project_handle, self._entry_index, prop)

            elif data_type == mm.M2IO_STRING_TYPE:
                return mmproj.mmproj_index_entry_get_string_data(
                    self._project_handle, self._entry_index, prop)
        except mm.MmException:
            raise KeyError("Could not retrieve property '%s'" % prop)

    def __setitem__(self, prop, value):
        """
        Set entry property value.
        """
        try:
            data_type = self._getPropertyType(prop)
        except KeyError:
            # The property does not exist.  Assume the property name
            # is a valid m2io data name and try to create it.
            # Check whether the property name has invalid characters
            # Note this doesn't mean it's necessarily a valid
            # m2io data name
            if mmproj.mmproj_entity_name_is_invalid("property", prop, False):
                raise ValueError("The name \'%s\' is not a valid data name\n%s\n%s" % \
                                (prop,
                                 "Form is [i,b,s,r]_<author>_<name>.",
                                 "For example: i_herc_my_property"))

            # If got here, property name is valid, will add a new property.
            data_type = mm.m2io_get_type_by_name(prop)

        # Set the value of the correct type
        if data_type == mm.M2IO_REAL_TYPE:
            mmproj.mmproj_index_entry_set_real_data(
                self._project_handle, self._entry_index, prop, value)
        elif data_type == mm.M2IO_BOOLEAN_TYPE:
            # Pass in as an integer
            value = 1 if value else 0
            mmproj.mmproj_index_entry_set_boolean_data(
                self._project_handle, self._entry_index, prop, value)

        elif data_type == mm.M2IO_INT_TYPE:
            mmproj.mmproj_index_entry_set_integer_data(
                self._project_handle, self._entry_index, prop, value)
        elif data_type == mm.M2IO_STRING_TYPE:
            mmproj.mmproj_index_entry_set_string_data(
                self._project_handle, self._entry_index, prop, value)
        else:
            raise ValueError('Unknown data_type: %s' % data_type)

    def __delitem__(self, prop):
        """
        Delete the given entry property.
        """

        # A way to check for existence of the property, and to verify that
        # it is specified as a "data" name. Can raise KeyError or ValueError:
        self._getPropertyType(prop)

        try:
            mmproj.mmproj_index_entry_delete_property(self._project_handle,
                                                      self._entry_index, prop)
        except mm.MmException:
            raise RuntimeError("Could not delete property '%s'" % prop)

        self._row._pt.update()

    def keys(self):
        """
        Return a list of data names of all properties available in this entry.
        """

        # TODO an optimized mmproj API for this would be helpful
        entry_props = []
        for prop in self._row._pt.getPropertyNames():
            try:
                self[prop]
            except KeyError:
                # If this property is present in the PT, but is missing for
                # this specific entry, skip it.
                continue
            else:
                entry_props.append(prop)
        return entry_props

    def __len__(self):
        return len(self.keys())

    def __iter__(self):
        return iter(self.keys())


class ProjectRow(object):  # Must descend from object for properties to work
    """
    ProjectRow allows access to the structure and properties of an entry
    in the project.  It is an access mechanism and not a completely
    self-contained object - project row objects should be treated as
    transient, they may become invalid by subsequent project operations.

    This class represents a project entry.
    Each row is linked to one entry, but row != entry

    There are 3 ways to identify a row:

       * ProjectRow.index: Internal project order, entry in project - NOT sorted
            and NOT same as entry_id.
       * ProjectRow.row_number: Table row position - changes when table is
            sorted
       * ProjectRow.entry_id: ID of the entry represented by this row, never
            changes.

    Normally it won't be necessary to create an explicit ProjectRow object,
    one will be returned by indexing into a ProjectTable object

    """

    # ***********************************************************************
    # Constructor
    # ***********************************************************************
    def __init__(self, pt, row_index):
        """
        Construct a ProjectRow for the given Project instance
        based on an entry index.
        """
        self._pt = pt
        self._project_handle = pt.handle
        self._entry_index = row_index
        self._property = None

    # ***********************************************************************
    #
    # ***********************************************************************
    def __repr__(self):
        """
        Return string representation
        """

        return "ProjectRow: Project handle %d, row %d" % \
               (self._project_handle, self._entry_index)

    def __eq__(self, other):
        """
        Test ProjectRow equality by project handle and entry index.
        """

        try:
            if (self._project_handle == other._project_handle) and \
                (self._entry_index == other._entry_index):
                return True
            else:
                return False
        except AttributeError:
            return False

    def __ne__(self, other):
        """
        Test ProjectRow inequality by project handle and entry index.
        """
        return not self.__eq__(other)

    # ***********************************************************************
    #
    # ***********************************************************************
    def getStructure(self, props=True, copy=True, workspace_sync=True):
        """
        :return: The entry's structure
        :rtype: `structure.Structure`

        :param props: Whether the associated PT properties are included in the
                returned structure (default).
        :type props: bool

        :param copy: Whether to return a copy of the PT structure (default). If
                set to False, returns the original CT for the entry. Such use should
                in general be avoided, except as an optimization.
                NOTE: With copy=False, when the returned CT is modified, the changes
                are instantly propagated to the PT, but not the Workspace, and
                changes to properties do not propagate. Unless it's certain that
                properties didn't change, and that the structure is not included in
                the Workspace, any changes to it should be followed up by a call to
                setStructure().

        :param workspace_sync: If this entry is included in Workspace, sync
                the Workspace with Project Table before retreiving entry's
                structure. As an optimization, when getStructure() is called
                in a loop, call maestro.project_table_synchronize(), then
                call getStructure() with workspace_sync=False for each entry.

        WARNING: The current default (copy=True) is to make a duplicate
        of the entry's structure.  These will be marked for garbage
        collection once they go out of scope in the calling code, but if,
        for example, you are in a loop your memory usage will grow until
        the loop is exited (and it may even take a while for it to drop
        since garbage collection is not necessarily immediate).  This can
        cause large memory usage if, for example,  you are iterating over
        a large number entries.  In some cases you may want to
        explicitly delete the returned Structure.  For example,
        in a loop iterating over a large number of entries you may want to
        delete the Structure while still in the loop (after you're
        done processing the Structure) to prevent memory bloat.
        """

        if _in_maestro and workspace_sync and self.in_workspace:
            # EV 57529 - Make sure any outstanding Workspace changes
            # have a chance to be synched with the Project.  Otherwise,
            # outstanding changes to the Workspace structure will be lost.
            # Note: whether the project is actually modified depends on
            # the user's Maestro projectsync preference.  So it's possible
            # the Workspace changes won't be placed back into
            # the project.  See docstring for project_table_synchronize().
            maestro.project_table_synchronize()

        if props:
            ct = mmproj.mmproj_index_entry_get_ct_and_prop(
                self._project_handle, self._entry_index)
        else:
            ct = mmproj.mmproj_index_entry_get_ct(self._project_handle,
                                                  self._entry_index)

        # Make a copy to return a Python-managed Structure that the
        # user will be in total control of Ev:59662
        if copy:
            ct_copy = mm.mmct_ct_duplicate(ct)
            # Free up the memory used by the CT:
            mmproj.mmproj_index_entry_free_ct_and_prop(self._project_handle,
                                                       self._entry_index, ct)
            return structure.Structure(ct_copy)
        else:  # Return a reference to the ORIGINAL ct:
            # Store so that we can free up the memory later
            self._pt._cached_entries.add(self._entry_index)
            struct = structure.Structure(ct)
            struct._cpp_structure.releaseOwnership()
            return struct

    # ***********************************************************************
    #
    # ***********************************************************************
    def _getStructure(self):
        """
        See docstring for structure property
        """
        import warnings
        msg = "ProjectRow.structure API is deprecated; use ProjectRow.getStructure() instead."
        warnings.warn(msg, DeprecationWarning, stacklevel=2)

        if _in_maestro:
            # EV 57529 - Make sure any outstanding Workspace changes
            # have a chance to be synched with the Project.  Otherwise,
            # outstanding changes to the Workspace structure will be lost.
            # Note: whether the project is actually modified depends on
            # the user's Maestro projectsync preference.  So it's possible
            # the Workspace changes won't be placed back into
            # the project.  See docstring for project_table_synchronize().
            maestro.project_table_synchronize()

        ct = mmproj.mmproj_index_entry_get_ct(self._project_handle,
                                              self._entry_index)

        # Store so that we can free up the memory later
        self._pt._cached_entries.add(self._entry_index)
        struct = structure.Structure(ct)
        struct._cpp_structure.releaseOwnership()
        return struct

    # ***********************************************************************
    #
    # ***********************************************************************
    def setStructure(self, struct, props=True, copy=True, sync_workspace=True):
        """
        Set the structure of the entry to the specified structure.
        If the entry is included in the Workspace, the Workspace CT will be
        updated accordingly.

        :param struct: Set the entry to this Structure object
        :type struct: schrodinger.structure.Structure

        :param copy: If True, a copy of the Structure (CT) is made and that
            copy is used to set the entry. If False, the original Structure,
            struct, is placed into the project table.  Doing this hands off
            control of struct and you should no longer use struct.
        :type copy: bool

        :param props: If True, update properties in the entry. If False,
            properties are ignored.
        :type props: bool

        :param sync_workspace: Whether to update the maestro workspace
        :type sync_workspace: bool
        """

        # Note - related to 36858 make a copy of the CT by default because the
        # entry will have complete control over this CT:
        if copy:
            struct = mm.mmct_ct_duplicate(struct)

        if props:
            # Update structure and properties:
            mmproj.mmproj_index_entry_set_ct_and_prop(self._project_handle,
                                                      self._entry_index, struct)
        else:
            # Update structure only:
            mmproj.mmproj_index_entry_set_ct(self._project_handle,
                                             self._entry_index, struct,
                                             0)  # <- Ev:57721

        # Update the Workspace if this entry is included:
        if _in_maestro and self.in_workspace and sync_workspace:
            maestro.command(
                'synchronizeworkspacefromproject entry "%s"' % self.entry_id)

        # Store so that we can free up the memory later
        # FIXME we need to do this only if copy is False, right?
        self._pt._cached_entries.add(self._entry_index)

    # ***********************************************************************
    #
    # ***********************************************************************
    def _setStructure(self, struct, copy=True):
        """
        See docstring for structure property
        """
        import warnings
        msg = "ProjectRow.structure API is deprecated; use ProjectRow.setStructure() instead."
        warnings.warn(msg, DeprecationWarning, stacklevel=2)

        # Note - related to 36858 make a copy of the CT by default because the
        # entry will have complete control over this CT:
        if copy:
            struct = mm.mmct_ct_duplicate(struct)

        mmproj.mmproj_index_entry_set_ct(
            self._project_handle, self._entry_index, struct, 0)  # <- Ev:57721

        # Store so that we can free up the memory later
        self._pt._cached_entries.add(self._entry_index)

    #####################################################################
    # Treat 'structure' as a Python property
    #####################################################################
    structure = property(
        _getStructure,
        _setStructure,
        doc="""
    This attribute is deprecated. Please use ProjectRow.getStructure() and
    ProjectRow.setStructure() instead.
    """)

    # ***********************************************************************
    #
    # ***********************************************************************
    def _userNameToDataName(self, prop):
        """
        Given a property name (either user name or data name) return the
        data name. If the property is not present, return the input.
        Used to implement deperecated API for accessing properties by
        user names, and will be removed in the future.
        """
        try:
            i = mmproj.mmproj_project_get_property_index(
                self._project_handle, prop)
        except mm.MmException:
            return prop

        return mmproj.mmproj_property_get_data_name(self._project_handle, i)

    def __getitem__(self, prop):
        """
        Deprecated, instead use:
            value = ProjectRow.property[prop]
        """

        data_name = self._userNameToDataName(prop)
        return self.property.get(data_name)

    # ***********************************************************************
    #
    # ***********************************************************************
    def __setitem__(self, prop, value):
        """
        Deprecated, instead use:
            ProjectRow.property[prop] = value
        """

        data_name = self._userNameToDataName(prop)
        try:
            self.property[data_name] = value
        except RuntimeError as err:
            # This original API ignores errors
            Project._printDebug("ProjectRow::__setitem__ - %s" % err)

    # ***********************************************************************
    #
    # ***********************************************************************
    def __delitem__(self, prop):
        """
        Deprecated, instead use:
            del ProjectRow.property[prop]
        """

        data_name = self._userNameToDataName(prop)
        try:
            del self.property[data_name]
        except (KeyError, ValueError) as err:
            # This original API ignores errors
            Project._printDebug("ProjectRow::__delitem__ - %s" % err)

    def inWorkspace(self):
        """
        Obsolete. Use ProjectRow.in_workspace property instead.
        """

        import warnings
        msg = "ProjectRow.inWorkspace() is obslete. Use the in_workspace property instead."
        warnings.warn(msg, DeprecationWarning, stacklevel=2)

        return self.in_workspace

    def includeOnly(self, scroll_to_row=False):
        """
        Include this entry in the workspace and exclude all other entries.

        :param scroll_to_row: If True, scroll to the included row in case it's out of view.
        :type scroll_to_row: bool
        """

        if _in_maestro:

            cmd = 'entrywsincludeonly entry "%s"' % self.entry_id
            maestro.command(cmd)
            if scroll_to_row:
                maestro_ui.MaestroHub.instance().scrollToEntryID.emit(
                    int(self.entry_id))
            return

        # We are not in maestro
        # First remove all the included entries
        # Do this in reverse order as the array shrinks
        num_included_entries = \
              mmproj.mmproj_project_get_included_entry_total(self._project_handle)

        for i in range(num_included_entries, 0, -1):
            entry = \
                  mmproj.mmproj_project_get_included_entry(self._project_handle,
                                                       i)
            ws_state = \
                  mmproj.mmproj_index_entry_get_workspace_state(self._project_handle,
                                                            entry)
            if ws_state != mmproj.MMPROJ_ENTRY_LOCKED_IN_WORKSPACE:
                mmproj.mmproj_index_entry_set_workspace_state(
                    self._project_handle, entry,
                    mmproj.MMPROJ_ENTRY_NOT_IN_WORKSPACE)

        # Include just this one entry
        entry_index = \
                mmproj.mmproj_project_entry_id_to_index(self._project_handle,
                                                    int(self.entry_id))

        mmproj.mmproj_index_entry_set_workspace_state(
            self._project_handle, entry_index, mmproj.MMPROJ_ENTRY_IN_WORKSPACE)

    def selectOnly(self):
        """
        Select this entry and de-select all other entries in the Project Table.
        """
        self._pt.selectRows(REPLACE, entry_ids=[self.entry_id])

    def delete(self):
        """
        Delete this row/entry from the project.
        """
        self._pt._deleteRowByIndex(self._entry_index)

    # Methods for getting and setting the title of the entry:
    # Added for consistency with Structure objects
    def _getTitle(self):
        return self.property['s_m_title']

    def _setTitle(self, title):
        self.property['s_m_title'] = title

    title = property(_getTitle, _setTitle, doc="The title of the entry")

    # Internal project index of the row (not visible to the user). Will change
    # when rows above it are deleted, but not when table is re-sorted:
    def _getProjectIndex(self):
        """
        The index property is not the same as Project Table row.
        This property indicates what order entries were added to the project.
        This can change when an entry in the project is deleted.
        """
        return self._entry_index

    index = property(
        _getProjectIndex,
        doc="Internal Project index of the row. "
        "This is different from Project Table row number or from entry ID.")

    # Get the project table (visible) row number of this entry; will change if
    # rows are deleted or table is re-sorted:
    @property
    def row_number(self):
        """
        This is the Project Table row number, as it appears to the user in
        Maestro. It is different from the internal row index.
        """
        return mmproj.mmproj_table_get_entry_row(self._project_handle, 1,
                                                 self._entry_index)

    # Get the entry ID string (it is different from the entry index); it
    # does not change even if rows are deleted or table is re-sorted:
    def _getEntryID(self):
        return self.property[mm.M2IO_DATA_CT_ENTRY_ID]  # 's_m_entry_id'

    entry_id = property(_getEntryID, doc="Entry ID of the row")

    # Get EntryGroup object for the group of this entry:
    def _getGroup(self):
        groupi = mmproj.mmproj_table_index_entry_get_group(
            self._project_handle, 1, self._entry_index)
        if groupi:
            return EntryGroup(self._pt, groupi)
        else:
            return None

    group = property(_getGroup, doc="EntryGroup for the row")

    def _getEntryInWorkspace(self):
        # FIXME need to fix the SWIG wrapper
        inws = mmproj.mmproj_index_entry_get_workspace_state(
            self._project_handle, self._entry_index)

        # Possible returns:
        # MMPROJ_ENTRY_NOT_IN_WORKSPACE (0)
        # MMPROJ_ENTRY_IN_WORKSPACE (1)
        # MMPROJ_ENTRY_LOCKED_IN_WORKSPACE (2)
        return inws

    def _setEntryInWorkspace(self, include):
        # In Maestro we want to use the commands as it has to ensure
        # other state in Maestro is properly kept in synch.
        if _in_maestro:
            if include == LOCKED_IN_WORKSPACE:
                cmd = 'entrywsincludelock entry "%s"' % self.entry_id
            elif include:
                # True or IN_WORKSPACE
                cmd = 'entrywsinclude entry "%s"' % self.entry_id
            else:
                # False or NOT_IN_WORKSPACE
                cmd = 'entrywsexclude entry "%s"' % self.entry_id
            maestro.command(cmd)

        else:  # Not in Maestro
            mmproj.mmproj_index_entry_set_workspace_state(
                self._project_handle, self._entry_index, include)

    doc = "Inclusion state of the entry (NOT_IN_WORKSPACE/IN_WORKSPACE/LOCKED_IN_WORKSPACE)"
    doc += "\nWARNING: This property should NOT be treated as a boolean."
    in_workspace = property(_getEntryInWorkspace, _setEntryInWorkspace, doc=doc)

    def _getEntryIsSelected(self):
        issel = mmproj.mmproj_index_entry_get_select_state(
            self._project_handle, self._entry_index)
        return bool(issel)  # More Pythonic to return True or False

    def _setEntryIsSelected(self, select):
        if select:
            mmproj_select = mmproj.MMPROJ_ENTRY_SELECTED
        else:
            mmproj_select = mmproj.MMPROJ_ENTRY_NOT_SELECTED

        try:
            mmproj.mmproj_index_entry_set_select_state(
                self._project_handle, self._entry_index, mmproj_select)

        except mm.MmException:
            print("%s.is_select: Could not select row: %d" % \
                (_module_name, self._entry_index))

        self._pt.update()

    is_selected = property(
        _getEntryIsSelected,
        _setEntryIsSelected,
        doc="Whether the entry is selected")

    def _getEntryIsReadOnly(self):
        read_only = mmproj.mmproj_index_entry_get_read_only(
            self._project_handle, self._entry_index)
        return bool(read_only)

    def _setEntryIsReadOnly(self, is_read_only):
        if is_read_only:
            mmproj.mmproj_index_entry_set_read_only(
                self._project_handle, self._entry_index,
                mmproj.MMPROJ_ENTRY_READ_ONLY)
        else:
            mmproj.mmproj_index_entry_set_read_only(
                self._project_handle, self._entry_index,
                mmproj.MMPROJ_ENTRY_NOT_READ_ONLY)
        return

    read_only = property(
        _getEntryIsReadOnly,
        _setEntryIsReadOnly,
        doc="Whether the entry is read only or not")

    def _getEntryIsDeletable(self):
        deletable = mmproj.mmproj_index_entry_get_deletable(
            self._project_handle, self._entry_index)
        return bool(deletable)

    def _setEntryIsDeletable(self, deletable):
        if deletable:
            mmproj.mmproj_index_entry_set_deletable(
                self._project_handle, self._entry_index,
                mmproj.MMPROJ_ENTRY_DELETABLE)
        else:
            mmproj.mmproj_index_entry_set_deletable(
                self._project_handle, self._entry_index,
                mmproj.MMPROJ_ENTRY_NOT_DELETABLE)
        return

    deletable = property(
        _getEntryIsDeletable,
        _setEntryIsDeletable,
        doc="Whether the entry is deletable or not")

    #**********************************************************************
    def _getCMSStructureReader(self):
        """
        If there is no associated cms file, then we return an empty
        iterator.  This allows you to always just write a loop::

           for i in cms_structure_reader:

        and avoid checking if cms_structure_reader exists.  If it
        does not, the loop will do nothing.

        If there is an associated cms file, then we return a
        StructureReader that allows one to retrieve the data by iterating.
        """
        cms_file = self.cms_file
        if not cms_file:
            return EmptyIterator()

        sreader = structure.StructureReader(cms_file)
        return (sreader)

    cms_structure_reader = property(
        _getCMSStructureReader,
        doc=
        "Return StructureReader for associated CMS file or EmptyIterator if there is no associated file"
    )

    def _getCMSFile(self):
        """
        Returns the file instead of the reader. This is more convenient to use
        than the reader in some cases.
        If there is no associated cms file, then we return None.
        """

        # If there is no cms property, then return None
        cms_name = self.property.get('s_m_original_cms_file')
        if cms_name is None:
            return None

        # Build up the path to the file.
        # WARNING: Applications should avoid using
        # mmproj_index_entry_get_additional_data_dir as the underlying
        # location could change for any release.  Our own python
        # modules should exercise caution when using this function for
        # the same reason.
        additional_dir = \
                 mmproj.mmproj_index_entry_get_additional_data_dir(self._project_handle,
                                                            self.index)
        return additional_dir + '/' + cms_name

    cms_file = property(
        _getCMSFile,
        doc="Return associated CMS file or None if there is no associated file")

    def _getSurfaces(self):
        """
        Returns an iterator to the available surfaces
        """

        return _EntrySurfaceIterator(self._pt, self.entry_id)

    surfaces = property(
        _getSurfaces,
        doc="Return an interator to the surface objects available for this entry"
    )

    def newMolecularSurface(self, *args, **kwargs):
        """
        Create a new molecular surface for this row

        :param name: The name of the surface.  Note that project rows require
            all surfaces to be named uniquely.  See `overwrite`.
        :type name: str

        :param asl: If given, the surface will only be created for atoms in the
            structure that match the provided ASL.  Note that only one of `asl` and
            `atoms` may be given.  If neither are given, then the surface will be
            created for all atoms in the structure.
        :type asl: str or NoneType

        :param atoms: An optional list of atom numbers.  If given, the surface
            will only be created for the specified atoms.  Note that only one of
            `asl` and `atoms` may be given.  If neither are given, then the
            surface will be created for all atoms in the structure.
        :type atoms: list or NoneType

        :param resolution: The resolution of the surface, generally between
            0 and 1.  Smaller numbers lead to a more highly detailed surface.
        :type resolution: float

        :param probe_radius: The radius of the rolling sphere used to calculate
            the surface.  Defaults to 1.4 if `mol_surf_type` is
            `surface.MolSurfType.Molecular` or `surface.MolSurfType.Extended`.
            May not be given if `mol_surf_type` is `surface.MolSurfType.vdw`.
        :type probe_radius: float

        :param vdw_scaling: If given, all atomic radii will be scaled by the
            provided value before the surface is calculated.
        :type vdw_scaling: float

        :param mol_surf_type: The type of surface to create.
        :type mol_surf_type: `surface.MolSurfType`

        :param overwrite: What to do if the new surface has the same name as an
            existing surface for this project row.  If True, the existing surface
            will be overwritten.  In False, a ValueError will be raised.
        :type overwrite: bool

        :return: The new surface
        :rtype: `project_surface.Surface`
        """

        return project_surface.ProjectSurface.newMolecularSurface(
            self._pt, self, *args, **kwargs)

    @property
    def surface(self):
        """
        A dictionary of all surfaces for this row.  Keys are surface names and
        values are `project_surface.Surface` objects.
        :type: `project_surface.SurfaceDict`
        """

        return project_surface.SurfaceDict(self._pt, self)

    def moveToGroup(self, group_name):
        """
        Move this entry to group. If group does not exist it will be
        created.

        :param group_name: Name of group to which to move this entry. If such
            group doesn't exist, it will be created. Note, this is different
            from the user-visible group title.
        :type group_name: str
        """

        # FIXME: Factor out duplication with Project.moveRowsToGroup().
        # See PANEL-11205

        # if group does not exist, create it first
        group_exist = mmproj.mmproj_table_does_group_exist(
            self._project_handle, 1, group_name)

        if self._pt.isCurrentMaestroProject():
            if not group_exist:
                cmd = 'entrygroupcreate "%s" entry "%s"' % \
                    (group_name, self.entry_id)
            else:
                cmd = 'entrymovetogroup "%s" entry "%s"' % \
                        (group_name, self.entry_id)
            maestro.command(cmd)
        else:
            if not group_exist:
                title = group_name
                mmproj.mmproj_table_add_entry_group(self._project_handle, 1,
                                                    group_name, title, False)

            group_index = mmproj.mmproj_table_get_entry_group_index(
                self._project_handle, 1, group_name)

            mmproj.mmproj_table_index_entry_move_and_set_group(
                self._project_handle, self._entry_index, group_index)
            self._pt.update()

    def ungroup(self):
        """
        Remove this entry from its current group.
        """

        if self._pt.isCurrentMaestroProject():
            cmd = 'ungroupentries entry "%s"' % self.entry_id
            maestro.command(cmd)
        else:
            size = \
                mmproj.mmproj_table_get_row_total(self._project_handle, 1)
            bs = mmbitset.Bitset(size=size)
            entry_index = \
                mmproj.mmproj_project_entry_id_to_index(self._project_handle,
                                                    int(self.entry_id))
            mm.mmbs_on(bs, entry_index)
            mmproj.mmproj_table_ungroup_entries(self._project_handle, 1, bs)
            self._pt.update()

    @property
    def property(self):
        """
        Dictionary-like container of entry properties.  Keys are strings
        of the form `type_family_name` as described in `structure.PropertyName`
        documentation.
        """
        if self._property is None:
            self._property = _ProjectRowProperty(self)
        return self._property


# ***************************************************************************
# ***************************************************************************


class _AllRowsIterator:
    """ Iterator for all rows in the project or group """

    def __init__(self, pt, group=None):
        """
        :param pt: Project table instance
        :type pt: `Project`

        :param group: Group (if iterating over entries in a group)
        :type group: `EntryGroup` or None (default)
        """
        self._pt = pt
        self._project_handle = pt.handle
        self._group = group

    def __iter__(self):
        # We may need to use this to get only the visible table rows:
        # num_rows = mmproj.mmproj_table_get_row_total(self._project_handle, table)
        # But there should be a way the get thr ROW NUMBERS for those rows
        # as opposed to just the number of visible table rows.

        if self._group:
            # We are iterating over rows in a group
            for project_index in self._group._getAllEntryIndices():
                if self._pt._auto_free_cache:
                    self._pt.freeCachedEntries()
                yield ProjectRow(self._pt, project_index)
        else:
            # For each row in the table (position by table order):
            num_rows = mmproj.mmproj_project_get_entry_total(
                self._project_handle)
            for row_position in range(1, num_rows + 1):
                # Convert row position to project entry index:
                project_index = mmproj.mmproj_table_get_row_entry_index(
                    self._project_handle, 1, row_position)
                if self._pt._auto_free_cache:
                    self._pt.freeCachedEntries()
                yield ProjectRow(self._pt, project_index)

    def __len__(self):
        """ Returns the total number of rows """
        if self._group:
            return mmproj.mmproj_table_get_group_entry_total(
                self._project_handle, 1, self._group._group_index)
        else:
            return mmproj.mmproj_project_get_entry_total(self._project_handle)


class _SelectedRowsIterator:
    """ Iterator for selected rows of the project or group"""

    def __init__(self, pt, group=None):
        """
        :param pt: Project table instance
        :type pt: `Project`

        :param group: Group (if iterating over entries in a group)
        :type group: `EntryGroup` or None (default)
        """
        self._pt = pt
        self._project_handle = pt.handle
        self._group = group

    def __iter__(self):
        # This always gets the selection from the project table order
        # (not directly from the entry order on disk) and then maps the
        # project table row to the project's entry index.  Project table
        # order can differ from the project order (which is what is on
        # the disk) and that's why we do the mapping

        # bitset of selected row indecies:
        sel_bs = mmproj.mmproj_table_get_selected_rows(self._project_handle, 1)

        if self._group:
            group_entry_indices = self._group._getAllEntryIndices()

        for sel_position in mmbitset.Bitset(sel_bs, manage_handle=False):
            # sel_position is the table row index
            # Convert row index into a project index:
            project_index = mmproj.mmproj_table_get_row_entry_index(self._project_handle,\
                                                            1, sel_position)

            if self._group:
                if project_index in group_entry_indices:
                    if self._pt._auto_free_cache:
                        self._pt.freeCachedEntries()
                    yield ProjectRow(self._pt, project_index)
            else:
                if self._pt._auto_free_cache:
                    self._pt.freeCachedEntries()
                yield ProjectRow(self._pt, project_index)

    def __len__(self):
        """ Returns the number of selected rows """
        sel_bs = mmproj.mmproj_table_get_selected_rows(self._project_handle, 1)

        if self._group:
            group_entry_indices = self._group._getAllEntryIndices()
            total = 0
            for sel_position in mmbitset.Bitset(sel_bs, manage_handle=False):
                project_index = mmproj.mmproj_table_get_row_entry_index(
                    self._project_handle,\
                    1, sel_position)

                if project_index in group_entry_indices:
                    total += 1

            return total
        else:
            return mm.mmbs_get_count(sel_bs)


# ***************************************************************************
# ***************************************************************************


class _IncludedRowsIterator:
    """
    Iterator for included rows of the project or group

    Order of iteration should be treated as "random".  That is
    the returned order is not necessarily the order in which entries were
    included into the Workspace, nor is it necessarily the order
    in which they appear in the Project Table.
    """

    def __init__(self, pt, group=None):
        """
        :param pt: Project table instance
        :type pt: `Project`

        :param group: Group (if iterating over entries in a group)
        :type group: `EntryGroup` or None (default)
        """
        self._pt = pt
        self._project_handle = pt.handle
        self._group = group

    def __iter__(self):

        total = \
              mmproj.mmproj_project_get_included_entry_total(self._project_handle)

        if self._group:
            group_entry_indices = self._group._getAllEntryIndices()
        # Do in reverse order in case this is being
        # used to exclude entries.  Probably bad idea to do
        # this with the iterator anyway, but just in case
        # someone tries.  See mmproj.h for details.
        for i in range(total, 0, -1):
            entry_index =  \
                        mmproj.mmproj_project_get_included_entry(self._project_handle, i)
            if self._group:
                if entry_index in group_entry_indices:
                    if self._pt._auto_free_cache:
                        self._pt.freeCachedEntries()
                    yield ProjectRow(self._pt, entry_index)
            else:
                if self._pt._auto_free_cache:
                    self._pt.freeCachedEntries()
                yield ProjectRow(self._pt, entry_index)

    def __len__(self):
        """Returns the number of included entries (rows)"""

        if self._group:
            group_entry_indices = self._group._getAllEntryIndices()
            total = 0
            tot = \
              mmproj.mmproj_project_get_included_entry_total(self._project_handle)
            for i in range(tot, 0, -1):
                entry_index =  \
                        mmproj.mmproj_project_get_included_entry(self._project_handle, i)
                if entry_index in group_entry_indices:
                    total += 1
        else:
            total = \
              mmproj.mmproj_project_get_included_entry_total(self._project_handle)

        return total


# ***************************************************************************
# ***************************************************************************


class EntryGroup(object):
    """
    A class which represents an entry group in the project table. Entry
    groups are returned from the Project.group property.

    The entry group itself has a number of properties:

    collapsed - set or get the entry collapse/expand state

    title - set or get the entry group title (What the user sees)

    name - set or get the entry group name (Hidden unique name)

    The following iterators are available which are very similar to
    those available from the Project class but only operate on the
    entries in the group:

    all_rows - an iterator for all the rows in the group

    selected_rows - an iterator for selected rows in the group

    included_rows - an iterator for included rows in the group

    """

    def __init__(self, pt, group_index):
        self._project_handle = pt.handle
        self._pt = pt
        self._group_index = group_index
        project = projectmodel.schrodinger.MM_Project(self._pt)
        self._rowmodel = project.getRowModel()

    def __index__(self):
        return self._group_index

    def _getName(self):
        """
        Return the unique ID (name) of this group. NOTE: This is is different
        from the group title (which is displayed in Maestro).
        """
        return mmproj.mmproj_table_get_entry_group_name(self._project_handle, 1,
                                                        self._group_index)

    def _setName(self, gname):
        """
        Change the unique ID (name) of this group. NOTE: This does not update
        the user-visible group title.
        """
        mmproj.mmproj_table_set_entry_group_name(self._project_handle, 1,
                                                 self._group_index, gname)
        self._pt.update()
        return

    # Treat unique 'name' as a Python property
    # FIXME: Do we really want to make this property so accessible?
    name = property(
        _getName, _setName, None,
        "Get and set the group name (NOTE: this is different from user-visible group title)"
    )

    def _getParent(self):
        """
        Return the name of parent group, empty if group is under root.
        """

        _, parent_group_id = self._rowmodel.getParentGroupId(self._getName())
        return parent_group_id

    def getParentGroup(self):
        """
        Return the parent EntryGroup, or None if this group is top-level.

        :return: Parent entry, or None
        :rtype: EntryGroup or None
        """
        parent_gid = self._getParent()
        if parent_gid == "":
            return None
        return self._pt.groups[parent_gid]

    def __str__(self):
        # FIXME: Should this really return the name as opposed to title?
        return self._getName()

    def _getTitle(self):
        """
        Return the title of this group. This is the user-visible "name" of the
        group.
        """
        return mmproj.mmproj_table_get_entry_group_title(
            self._project_handle, 1, self._group_index)

    def _setTitle(self, gtitle):
        """
        Set the title of this group. This is the user-visible "name" of the
        group.
        """
        mmproj.mmproj_table_set_entry_group_title(self._project_handle, 1,
                                                  self._group_index, gtitle)
        self._pt.update()
        return

    # Treat 'title' as a Python property
    title = property(
        _getTitle, _setTitle, None,
        "Get and set the title of this group, as displayed in the PT.")

    def _getCollapsed(self):
        """
        Return the collapsed state of this group
        """
        return mmproj.mmproj_table_index_is_entry_group_collapsed(
            self._project_handle, 1, self._group_index)

    def _setCollapsed(self, collapsed):
        """
        Set the collapsed state of this group
        """
        mmproj.mmproj_table_index_set_entry_group_collapsed(
            self._project_handle, 1, self._group_index, collapsed)
        self._pt.update()
        return

    # Treat 'collapsed' as a Python property
    collapsed = property(_getCollapsed, _setCollapsed, None,
                         "Get and set the collapsed state of this group")

    # all_rows - dynamic list of all rows in this project (table order)
    def _getAllRowsIterator(self):
        return _AllRowsIterator(self._pt, self)

    all_rows = property(
        _getAllRowsIterator,
        doc=
        "Iterator for all rows in the group using the visible project table order"
    )

    def _getAllEntryIndices(self):
        """
        Return all entry indices of the group, it also includes entries of
        children groups.
        """

        return self._rowmodel.getGroupAllEntryIndices(self._getName())

    # selected_rows - dynamic list of selected rows (table order)
    def _getSelectedRowsIterator(self):
        return _SelectedRowsIterator(self._pt, self)

    selected_rows = property(
        _getSelectedRowsIterator,
        doc=
        "Iterator for the selected rows in the group usig the visible project table order"
    )

    def _getIncludedRowsIterator(self):
        """
        Private method.  The property should be used to access the iterator.
        """
        return _IncludedRowsIterator(self._pt, self)

    # The property
    included_rows = property(
        _getIncludedRowsIterator,
        doc=
        "Iterator for all included rows in the group.  Order should be treated as random"
    )


class _EntryGroupIterator:
    """
    Iterator for entry groups in the project

    Order of iteration should be treated as "random".  That is
    the returned order is not necessarily necessarily the order
    in which they appear in the Project Table.
    """

    def __init__(self, pt):
        self._project_handle = pt.handle
        self._pt = pt

    def __iter__(self):

        total = mmproj.mmproj_table_get_group_total(self._project_handle, 1)
        for i in range(1, total + 1):
            yield EntryGroup(self._pt, i)

    def __len__(self):
        """Returns the number of groups """
        total = mmproj.mmproj_table_get_group_total(self._project_handle, 1)
        return total

    def __getitem__(self, group_id):
        """
        Return the EntryGroup object for the group with the specified group
        ID (name).

        :param group_id:
        :type group_id: str

        :return: Group object
        :rtype: EntryGroup
        """
        for group in self:
            if group.name == group_id:
                return group
        raise KeyError('No group with ID "%s" found' % group_id)


############################################################################


class EntrySurface(object):
    """
    A class for accessing the surfaces associated with a given entry. This
    class will usually only be created from the EntrySurfaceIterator
    """

    def __init__(self, pt, surface_name, surface_handle):
        self._project_handle = pt.handle
        self.name = surface_name
        self.surface_handle = surface_handle

    def _getSurfaceIncluded(self):
        """
        A private method that returns a boolean to indicate if the surface
        is included in the Workspace.
        """
        return mmsurf.mmsurf_get_visibility(self.surface_handle)

    # The property for whether the surface is included:
    included = property(
        _getSurfaceIncluded,
        doc="Whether the surface is included in the Workspace.")


class _EntrySurfaceIterator:
    """
    An iterator for the surfaces associated with a given entry.
    """

    def __init__(self, pt, entry_id):
        self._pt_handle = pt.handle
        self._pt = pt
        self._entry_id = entry_id

    def __iter__(self):

        surf_name = None
        if not mmproj.mmproj_entry_get_has_vis(self._pt_handle, self._entry_id):
            return

        while (1):
            if surf_name is None:
                try:
                    surf_name = mmproj.mmproj_entry_get_first_surface_name(
                        self._pt_handle, self._entry_id)
                except mm.MmException:
                    # If you delete the last surface in the entry, the has_vis
                    # flag does not get toggled
                    return
            else:
                try:
                    surf_name = mmproj.mmproj_entry_get_next_surface_name(
                        self._pt_handle, self._entry_id, surf_name)
                except:
                    return

            surf_handle = mmproj.mmproj_entry_get_surface(
                self._pt_handle, self._entry_id, surf_name)

            yield EntrySurface(self._pt, surf_name, surf_handle)


##############################################################################
##############################################################################
##############################################################################


class Project(mmobject.MmObject):
    """
    Class to handle Maestro Projects.  This is largely a wrapper to
    the underlying C library which stores all the state information.

    The Project class allows selection of rows in the project via
    various interfaces.

    A Maestro project may be accessed from within a Maestro session
    which has it open via maestro.project_table_get().

    A Maestro project may alternatively be accessed without Maestro running
    by specifying a the name of the project when creating a project object.


    See the doc string for the module for more details.

    """

    # ***************
    # Class variables
    # ***************

    # This dict will keep track of the number of objects for each handle, so
    # we know when to call mmproj_proj_close. This allows someone to use
    # foo = Project(1) as a way of gaining access to a garbage
    # collected project handle.  This is all handled in the
    # MmObject base class.
    # Required by MmObject base class
    _instances = {}

    # Controls whether debugging output is generated or not
    # On by default.  Class level, not instance.
    _debug = False

    # ***********************************************************************
    # Static method. Enable or disable debug output
    # ***********************************************************************
    def setDebug(state):
        """
        Enable or disable debug output.  Use False or True.
        Sets this for the class, i.e. affects all instances,
        current and future.
        """

        # Set at the class level, not for a given instance
        Project._debug = state

    setDebug = staticmethod(setDebug)

    # ***********************************************************************
    # Static printing method. Print out debug if enabled
    # *******************************************************************
    def _printDebug(*args):
        """
        Print debugging output if enabled
        """

        if Project._debug:
            print(args)

    _printDebug = staticmethod(_printDebug)

    # ***********************************************************************
    #
    # ***********************************************************************
    def enable(self, option=None):
        """
        Enable the specified option.  Currently only
        AUTOMATIC_CACHE_FREEING is available.  This will
        cause any cached entries to be freed automatically
        after each iteration when using the all_rows iterator.
        Other iterators do not allow automatic cache
        freeing to be enabled.
        """

        if (option == AUTOMATIC_CACHE_FREEING):
            self._auto_free_cache = True
            return

    # ***********************************************************************
    #
    # ***********************************************************************
    def disable(self, option=None):
        """
        Disable the specified option.  Currently only
        AUTOMATIC_CACHE_FREEING is available.  See enable() for
        details.
        """

        if (option == AUTOMATIC_CACHE_FREEING):
            self._auto_free_cache = False
            return

    # ***********************************************************************
    # Static initialization method.  Used to initialize various mmlibs
    # ***********************************************************************
    def initialize(error_handler=None):
        """
        Initialize necessary mmlibs (which will also implicitly
        initialize all the dependent mmlibs)
        """

        if error_handler is None:
            error_handler = mm.error_handler

        Project._printDebug("initialize(): Initializing mmlibs")
        mmproj.mmesl_initialize(error_handler)
        mmproj.mmproj_initialize(error_handler)

    initialize = staticmethod(initialize)

    # ***********************************************************************
    # Static termination method.  Used to terminate mmlibs libraries
    # ***********************************************************************
    def terminate():
        """
        Terminate various mmlibs (which also implicitly terminates
        all the libraries they are dependent upon)
        """

        mmproj.mmesl_terminate()
        mmproj.mmproj_terminate()

    terminate = staticmethod(terminate)

    # ***********************************************************************
    # Constructor
    # ***********************************************************************
    def __init__(self,
                 project_name="",
                 project_handle=INVALID_PROJECT_HANDLE,
                 manage=True,
                 show_cleanup_dialog_on_close=False):
        """
        Construct a Project instance either by opening a project file or using
        a handle to an already opened project.

        :param project_name: The name of the project to open
        :type project_name: str

        :param project_handle: The handle of an already open project

        :note: Either project_name or project_handle must be passed in, but not
               both

        :param manage: Whether to perform garbage collection and close project
                       when the project is delete or goes out of scope.
        :type manage: bool

        :param show_cleanup_dialog_on_close: Whether to block the process and
               show a clean up dialog when closing the project
        :type show_cleanup_dialog_on_close: bool

        :note: If show_cleanup_dialog_on_close is False, project_cleanup is run
               in process

        When a non-managed instance goes out of scope or is deleted the project
        is left open. This is desirable, for example, when you construct a
        python Project instance using the project handle of Maestro's currently
        opened project. This allows Maestro to continue working with the opened
        project after the python Project instance is gone. Otherwise, the
        project would be closed and Maestro left in an invalid state, thinking
        that the project was still open.
        """

        Project._printDebug("In __init__()")

        self._show_cleanup_dialog_on_close = show_cleanup_dialog_on_close

        if not project_name and project_handle == INVALID_PROJECT_HANDLE:
            raise ProjectException("Need to specify a project name or a handle")

        if project_name and project_handle != INVALID_PROJECT_HANDLE:
            raise ProjectException(
                "Cannot specify both project name and handle")

        if project_name:

            Project._printDebug("__init__(): opening via name")
            manage = True
            self.project_name = project_name

            # Open up the project and get a project handle

            Project._printDebug("__init()__: project name exists... :%s:" % \
                            project_name)

            if not os.path.exists(project_name):
                raise ProjectException(
                    "Project \'%s\' does not exist" % project_name)

            if project_name.endswith("zip") or project_name.endswith("ZIP"):
                raise ArchivedProjectException(
                    "Zipped projects must be unzipped first. "
                    "See schrodinger.project.unzip_project()")

            # Have to initialize here because this is before
            # the base class initialization.
            try:
                mmproj.mmproj_initialize(mm.error_handler)
            except mm.MmException:
                pass

            #Record fact that extra mmproj termination will be needed.
            self._extra_terminate = True

            if not mmproj.mmproj_is_project_dir(project_name):
                raise InvalidProjectException("\'%s\' is not a valid (.prj) project" % \
                      project_name)

            # See if we can read this version of entry data.
            # In a future version we should check workflow data
            # but we'll wait until there is more easily accessible
            # support in mmproj for this.
            current_version = mmproj.mmproj_get_current_entry_version()
            project_version = mmproj.mmproj_get_entry_version(project_name)

            if current_version != project_version:
                raise InvalidProjectVersion(
                      "Project \'%s\' cannot be opened. %s %s %s %s %s %s %s" %\
                      (project_name, "It uses version",
                       project_version, "\nbut version", current_version,
                       "is expected.  You may be able to run",
                       "\n$SCHRODINGER/utilities/project_convert",
                       "to convert it to the new format."))

            if mmproj.mmproj_project_is_locked(project_name):
                raise LockedProjectException(
                    "Project %s is locked" % project_name)

            try:
                tmp_handle = mmproj.mmproj_project_open(project_name)
            except mm.MmException as err:
                msg = "Project '%s' cannot be opened: %s" % (project_name,
                                                             str(err))
                raise ProjectException(msg)

            Project._printDebug("Successfully opened project")

            # Call base class initalization
            super(Project, self).__init__(tmp_handle, manage, mm.error_handler)

        elif int(project_handle) != INVALID_PROJECT_HANDLE:

            Project._printDebug("__init__(): using already opened handle")
            manage = False
            # Nothing to do as it's already been initialized and
            # we have a handle
            # Call base class initalization
            super(Project, self).__init__(project_handle, manage,
                                          mm.error_handler)

            if not mmproj.mmproj_project_in_use(self.handle):
                print("There is no active project for the handle %d." % \
                      self.handle)
                return

            self.project_name = mmproj.mmproj_project_get_path(self.handle)

        # By default update the project after each change (if in Maestro):
        self.manual_update = False

        # Entry cache is empty from the python perspective
        self._cached_entries = set()

        self._auto_free_cache = True
        self._already_closed = False

        # Create instance of MM_Project
        self.project_model = projectmodel.schrodinger.MM_Project(self.handle)

    # ***********************************************************************
    # Private deletion method. Required by MmObject base class
    # ***********************************************************************
    def _delete(self):
        """
        A function to delete this object. Required for MmObject
        interface.
        """

        if self._already_closed:
            # Don't re-close the project if closeImmediately() has already been
            # called
            pass
        elif self._show_cleanup_dialog_on_close:
            # Clean up in a separate process and show a cleanup dialog
            mmproj.mmproj_project_close(self.handle)
        else:
            # Clean up in process (blocking) and don't show a clean up dialog.
            mmproj.mmproj_project_close_with_cleanup(self.handle)

        if self._extra_terminate:
            mmproj.mmproj_terminate()

    def close(self):
        """
        Close the project immediately.  Call this method on projects after
        being done using them. Current maestro project can not be closed.
        Note that the project cleanup will occur in-process; cleanup can be
        delayed until the process exits (or Maestro is closed) by relying on
        garbage collector to close the project instead of explicitly calling
        the close() method - but then the project can not be used anywhere
        again until that happens.
        """
        if self.isCurrentMaestroProject():
            raise ProjectException(
                "A project can not be closed while it's in "
                "use by Maestro. Use projectclose maestro command instead.")

        if mmproj.mmproj_project_in_use(self.handle):
            mmproj.mmproj_project_close_with_cleanup(self.handle)
        self._already_closed = True

    # ***********************************************************************
    # Full string representation
    # ***********************************************************************
    def __repr__(self):
        """
        Return full string representation

        Warning: you cannot create another project instance
        for a project which is currently open.  That is, a project
        can only be opened by a single process at a time.
        """

        return "Project (%d)" % self.handle

    # ***********************************************************************
    # Human readable string
    # ***********************************************************************
    def __str__(self):
        """Return string representation"""

        return "Project Name (%s), handle (%d)" % \
               (self.project_name, self.handle)

    # ***********************************************************************
    # MMlibs mmproj handle
    # ***********************************************************************
    def __index__(self):
        """
        Return mmproj handle. Can be used with mmproj functions.
        """

        return self.handle

    def __len__(self):
        """
        Return the number of entries (rows) in the project
        """

        num_entries = mmproj.mmproj_project_get_entry_total(self.handle)

        return num_entries

    # ***********************************************************************
    # Allow retrieval of a an entry
    # ***********************************************************************
    def getRow(self, entry_id):
        """
        Retrieve a ProjectRow instance based on the entry ID.

        :type entry_id: int or str
        :param entry_id: is the entry ID of this project's entry.  This
            is the internally assigned identifier that is invariant for the
            life of the project.  The ID does not change even if other
            entries are deleted or the entries (rows) in the Project
            are sorted.  Entry ID can be passed in as a string also (e.g. "1").

        :rtype:  ProjectRow
        :return: ProjectRow if entry ID is valid
                 Otherwise, returns None.
        """

        try:
            entry_id2 = int(entry_id)
        except TypeError:
            raise TypeError("Entry IDs must be an integer or castable to one, "
                            "but %s is a %s" % (entry_id, type(entry_id)))
        except ValueError:
            raise ValueError("Entry IDs must be in integer form, "
                             "'%s' cannot be converted to an int" % entry_id)
        try:
            # Just let mmproj determine if it is a valid id
            row = mmproj.mmproj_project_entry_id_to_index(
                self.handle, entry_id2)
        except mm.MmException:
            # Now row with this entry ID found in the project.
            return None

        return ProjectRow(self, row)

    # ***********************************************************************
    # Provide sequence key style access
    # ***********************************************************************
    def __getitem__(self, entry):
        """
        Return project row object, ProjectRow, for the given entry ID.
        Same as Project.getRow() except that it raises KeyError instead of
        returning None if the row does not exist.

        The entry ID ('entry') can be specified as either an int or a str, and
        is used to look up the corresponding index into the project.  Older
        versions used to allow look up by entry name and treated an
        'entry' argument of type string as an entry name.  This is no
        longer valid and will generate an exception - the unique
        identifiers in projects are now entry IDs not entry names.

        If you find your memory usage becoming excessive,
        you may want to use freeCachedEntries().  See that
        method's docstring for more information.

        """

        row = self.getRow(entry)
        if row is None:
            raise KeyError("No row with such entry ID found")
        return row

    def __contains__(self, entry):
        """
        Determine if the project contains the specified entry ID

        :type entry_id: int or str
        :param entry_id: The entry ID to check the project for.

        :rtype: bool
        :return: True if the specified entry ID is present.  False otherwise.
        """

        try:
            row = self.getRow(entry)
        except (TypeError, ValueError):
            return False
        return row is not None

    def __delitem__(self, entry_id):
        """
        Delete the project row for the given entry ID.
        Obsolete; use Project.deleteRow(entry_id) instead.
        """
        import warnings
        msg = "del Project[entry_id] API is deprecated; use Project.deleteRow() instead."
        warnings.warn(msg, DeprecationWarning, stacklevel=2)

        self.deleteRow(entry_id)

    def deleteRow(self, entry_id):
        """
        Delete the row with the given entry ID from this project.
        """
        try:
            entry = int(entry_id)
        except ValueError:
            raise ValueError("Entry IDs must be in integer form, "
                             "'%s' cannot be converted to an int" % entry_id)

        try:
            # Just let mmproj determine if it is a valid id
            index = mmproj.mmproj_project_entry_id_to_index(self.handle, entry)
        except mm.MmException:
            raise KeyError("No row with such entry ID found")

        self._deleteRowByIndex(index)

    def _deleteRowByIndex(self, index):
        """
        Delete the row with the given index from the Project, and if it was
        the last entry in its group, remove that group as well.
        """
        mmproj.mmproj_project_delete_index_entry(self.handle, index)
        # The HPT can't handle empty groups
        self.project_model.getRowModel().deleteEmptyGroups()

    def update(self):
        """
        If running in Maestro, update the project after a change as the
        user wants to see their changes in the PT.
        Does not do anything if the manual_update attribute is set to True.
        """
        if _in_maestro and not self.manual_update:
            maestro.project_table_update()

    def findRowsMatching(self, prop, cmp_op, value):
        """
        Returns a list of ProjectRow entries for all rows matching the given
        criteria.

        cmp_op should be "<", "<=", ">", ">=", "==", "!="
        """
        if cmp_op not in ("<", "<=", ">", ">=", "==", "!="):
            raise ValueError("Invalid operator: %s;" % cmp_op +
                             ' Allowed values: <, <=, >, >=, ==, !=.')

        data_type = mm.m2io_get_type_by_name(prop)

        # Set the value of the correct type
        if data_type == mm.M2IO_REAL_TYPE:
            assert type(value) == float
            func = mmproj.mmproj_property_real_get_matching_entries

        elif data_type == mm.M2IO_BOOLEAN_TYPE:
            assert type(value) == bool
            value = 1 if value else 0
            raise NotImplementedError("Entry matching by boolean properties "
                                      "is not supported")
            # There appears to be an issue where the function
            # mmproj_property_bool_get_matching_entries() is not producing
            # the right results.

        elif data_type == mm.M2IO_INT_TYPE:
            assert type(value) == int
            func = mmproj.mmproj_property_int_get_matching_entries

        elif data_type == mm.M2IO_STRING_TYPE:
            assert type(value) == str
            if '"' not in value:
                value = '"%s"' % value
            elif "'" not in value:
                value = "'%s'" % value
            else:
                raise ValueError("value contains both single and double quotes")
            func = mmproj.mmproj_property_string_get_matching_entries

        bs_handle = func(self.handle, prop, value, cmp_op)
        bs = mmbitset.Bitset(bs_handle)
        return [ProjectRow(self, entry_index) for entry_index in bs]

    def getSelectedRowTotal(self):
        """
        Return the total number of selected rows
        """

        # 1 is the only table we have in Maestro (at the moment)
        sel_bs = mmproj.mmproj_table_get_selected_rows(self.handle, 1)
        return mm.mmbs_get_count(sel_bs)

    # ***********************************************************************
    # Select rows in the project.
    # ***********************************************************************
    def selectRows(self, select_mode=REPLACE, *args, **keywords):
        """
        Select rows in the project. Valid modes of selection are:

        * project.ADD - add rows to existing PT selection.
        * project.REPLACE - replace current selection.
        * project.INVERT - invert the PT selection.
        * project.ALL - select all rows in the PT.
        * project.NONE - deselect all rows in the PT.

        Examples::

              pt.selectRows(project.REPLACE, entry_ids=[1, 2, 3])
              pt.selectRows(project.ALL)
              pt.selectRows(project.NONE)
              pt.selectRows(entry_ids=[1, 2, 3])
              pt.selectRows(ADD, esl="entry_re entry*")

        :param select_mode: Selection mode.

        :param entry_ids: List of entry IDs for the rows to select.
        :type entry_ids: list(int) or list(str)

        :param esl: This is an ESL definition.
        :type esl: str

        :param rows: Project indices for the rows to select (deprecated).
                     Values refer to the values of the ProjectRow.index property.
        :param rows: list(int)

        :param function: Callback for determining whether a row should be
                         selected or not (deprecated).
        :type function: callable
        """

        if (select_mode == ADD):
            Project._printDebug("ADD")
            self._selectAdd(*args, **keywords)
        elif (select_mode == REPLACE):
            Project._printDebug("REPLACE")
            self._selectNone()
            self._selectAdd(*args, **keywords)
        elif (select_mode == NONE):
            Project._printDebug("NONE")
            self._selectNone()
        elif (select_mode == ALL):
            Project._printDebug("ALL")
            self._selectAll()
        elif (select_mode == INVERT):
            Project._printDebug("INVERT")
            self._selectInvert()
        else:
            raise ValueError("Unrecognized select_mode value: %s" % select_mode)

        # If this script is being run from within Maestro, then
        # user really wants to operate on the PT.  So we need to
        # let them see their changes.
        self.update()

    # ***********************************************************************
    # Private method to add to the selection
    # ***********************************************************************
    def _selectAdd(self, *args, **keywords):
        """
        Private method to add specified rows to the already
        selected rows
        """

        for option, value in keywords.items():
            if option == "entry_ids":
                Project._printDebug("Select by entry IDs")
                for entry_id in value:
                    try:
                        entry_id = int(entry_id)
                    except TypeError:
                        raise TypeError("Entry IDs must be an integer or "
                                        "castable to one, but %s is a %s" %
                                        (entry_id, type(entry_id)))
                    try:
                        entry_index = mmproj.mmproj_project_entry_id_to_index(
                            self.handle, entry_id)
                    except mm.MmException:
                        raise ValueError("No row with entry ID: %i" % entry_id)
                    try:
                        mmproj.mmproj_index_entry_set_select_state(
                            self.handle, entry_index,
                            mmproj.MMPROJ_ENTRY_SELECTED)
                    except mm.MmException:
                        print("%s.selectRows(): Could not select row %d %s" \
                            % (_module_name, entry_index, "by entry ID"))

            elif option == "rows":
                # Add each row to the selection if within range
                Project._printDebug("by rows")

                import warnings
                msg = "Project.selectRows(): The rows option is deprecated."
                warnings.warn(msg, DeprecationWarning, stacklevel=3)

                try:
                    num_entries = mmproj.mmproj_project_get_entry_total(
                        self.handle)
                except mm.MmException:
                    print("%s.selectRows(): Could not get entry total" % \
                          _module_name)
                    return
                for r in value:
                    if r > num_entries:
                        print("Warning: Row %d exceeds table end which is %d. %s" % \
                              (r, num_entries, "Skipping."))
                    elif r < 1:
                        print("Warning: Rows start at 1.  Ignoring %d" % r)
                    else:
                        try:
                            mmproj.mmproj_index_entry_set_select_state(
                                self.handle, r, mmproj.MMPROJ_ENTRY_SELECTED)

                        except mm.MmException:
                            print("%s.selectRows(): %s %d %s" % \
                                  _module_name, \
                                  "Could not select row ", r, "by esl")
            elif option == "esl":
                # Add rows matching ESL
                bs = \
                   mmproj.mmesl_get_matching_entries(value, self.handle)

                # TODO: Need to add to mmbs.i
                # Make sure it's a valid handle
                # mm.mmbs_in_use(bs)

                for e in mmbitset.Bitset(bs):
                    try:
                        mmproj.mmproj_index_entry_set_select_state(
                            self.handle, e, mmproj.MMPROJ_ENTRY_SELECTED)
                    except mm.MmException:
                        print("%s.selectRows(): Could not select row %d" %\
                                  _module_name, e)
                        return

            else:
                raise ValueError("Unrecognized option: %s" % option)

    # ***********************************************************************
    # Private method to unselect all entry rows
    # ***********************************************************************
    def _selectNone(self):
        """
        Private method to unselect all entry rows

        The public API for de-selecting all rows is:
          Project.selectRows(NONE)
        """

        num_entries = mmproj.mmproj_project_get_entry_total(self.handle)

        # Ranges start at 0.  Project rows at 1.  Add 1 when using.
        for r in range(1, num_entries + 1):
            mmproj.mmproj_index_entry_set_select_state(
                self.handle, r, mmproj.MMPROJ_ENTRY_NOT_SELECTED)

    # ***********************************************************************
    # Private method to select all rows
    # ***********************************************************************
    def _selectAll(self):
        """
        Private method to select all rows

        The public API for selecting all rows is:
          Project.selectRows(ALL)
        """

        num_entries = mmproj.mmproj_project_get_entry_total(self.handle)

        # Ranges start at 0.  Project's at 1.  Add 1 when using.
        for r in range(1, num_entries + 1):
            mmproj.mmproj_index_entry_set_select_state(
                self.handle, r, mmproj.MMPROJ_ENTRY_SELECTED)

    # ***********************************************************************
    # Private method to invert to the selection
    # ***********************************************************************
    def _selectInvert(self):
        """
        Private method to invert all rows in the project

        The public API for inverting row selection is:
          Project.selectRows(INVERT)
        """

        num_entries = mmproj.mmproj_project_get_entry_total(self.handle)

        # Ranges start at 0.  Project's at 1.  Add 1 when using.
        for r in range(1, num_entries + 1):
            try:
                selstate = mmproj.mmproj_index_entry_get_select_state(
                    self.handle, r)
            except mm.MmException:
                raise Exception("Could not get selection state fora %d" % r)
                return

            if selstate == mmproj.MMPROJ_ENTRY_SELECTED:
                mmproj.mmproj_index_entry_set_select_state(
                    self.handle, r, mmproj.MMPROJ_ENTRY_NOT_SELECTED)
            else:
                mmproj.mmproj_index_entry_set_select_state(
                    self.handle, r, mmproj.MMPROJ_ENTRY_SELECTED)

    def includeRows(self, entry_ids, exclude_others=True, autofit=True):
        """
        Include rows with the given entry IDs in the Workspace, while
        optionally excluding all other entries. If entry_ids list is empty
        and exclude_others is True, all PT entries will be excluded.

        :param entry_ids: List of Entry IDs to include.
        :type entry_ids: list of ints or str.

        :param exclude_others: Whether to also exclude previously included entries.
        :type exclude_others: bool

        :param autofit: Whether to fit WS towards included entries
        :type autofit: bool
        """

        if _in_maestro:
            eid_str = ','.join(map(str, entry_ids))
            if exclude_others:
                if entry_ids:
                    if autofit:
                        maestro.command(
                            'entrywsincludeonly entry {0}'.format(eid_str))
                    else:
                        # Exclude, then re-include to avoid entrywsincludeonly's autofititing
                        # TODO: MAE-43603 - Do this in one command
                        maestro.command('beginundoblock Only include entries')
                        maestro.command('entrywsexclude all')
                        maestro.command(
                            f'entrywsinclude skipautofit=yes entry {eid_str}')
                        maestro.command('endundoblock')
                else:
                    maestro.command('entrywsexclude all')
            else:
                if entry_ids:
                    skip_autofit = 'no' if autofit else 'yes'
                    maestro.command(
                        f'entrywsinclude skipautofit={skip_autofit} entry {eid_str}'
                    )
            return

        # Outside of Maestro
        indices_to_include = {
            mmproj.mmproj_project_entry_id_to_index(self.handle, int(eid))
            for eid in entry_ids
        }

        if exclude_others:
            num_included_entries = \
                  mmproj.mmproj_project_get_included_entry_total(self.handle)
            # Do this in reverse order as the array shrinks
            for i in range(num_included_entries, 0, -1):
                entry_index = mmproj.mmproj_project_get_included_entry(
                    self.handle, i)
                # Do not exlclude entries that are in the input include list:
                if entry_index in indices_to_include:
                    indices_to_include.remove(entry_index)
                    continue
                ws_state = mmproj.mmproj_index_entry_get_workspace_state(
                    self.handle, entry_index)
                if ws_state != mmproj.MMPROJ_ENTRY_LOCKED_IN_WORKSPACE:
                    mmproj.mmproj_index_entry_set_workspace_state(
                        self.handle, entry_index,
                        mmproj.MMPROJ_ENTRY_NOT_IN_WORKSPACE)

        # Now include all entries that are not already included:
        for entry_index in indices_to_include:
            mmproj.mmproj_index_entry_set_workspace_state(
                self.handle, entry_index, mmproj.MMPROJ_ENTRY_IN_WORKSPACE)

    def moveRowsToGroup(self, entry_ids, group_name):
        """
        Move a list of project entries into a group.

        :param entry_ids: a list of project entry IDs
        :type entry_ids: list(str)

        :param group_name: The unique ID of a group; if a group with this
            name doesn't exist, it will be created. Note, this is different
            from the user-visible group title.
        :type group_name: str
        """
        # TODO: Consider requiring that the group exist already - that way
        # the behavior is more explicit. Project.createNewGroup() can be
        # used to create the group if it doesn't exist.

        if not entry_ids:
            return

        group_exists = mmproj.mmproj_table_does_group_exist(
            self.handle, 1, group_name)
        if self.isCurrentMaestroProject():
            eid_str = ', '.join(entry_ids)
            if group_exists:
                mae_command = 'entrymovetogroup'
            else:
                mae_command = 'entrygroupcreate'
            cmd = '{0} "{1}" entry_id "{2}"'.format(mae_command, group_name,
                                                    eid_str)
            maestro.command(cmd)
        else:
            if not group_exists:
                title = group_name
                mmproj.mmproj_table_add_entry_group(self.handle, 1, group_name,
                                                    title, False)

            group_index = mmproj.mmproj_table_get_entry_group_index(
                self.handle, 1, group_name)

            entry_bs = mmbitset.Bitset(size=len(self.all_rows))
            for entry_id in entry_ids:
                row = self.getRow(entry_id)
                entry_bs.set(row.index)

            mmproj.mmproj_table_index_entries_set_group(self.handle, 1,
                                                        entry_bs, group_index)

            self.update()

    # ***********************************************************************
    #
    # ***********************************************************************
    def __iter__(self):
        """
        Return an iterator object

        Allows iteration over the selected entries.

        To iterate over all entries use:
        \'for row in xrange(1, len(Project)+1)\'

        OBSOLETE. Use Project.selected_rows instead.

        If you find your memory usage becoming excessive,
        you may want to use freeCachedEntries().  See that
        method's docstring for more information.

        """

        import warnings
        msg = "'The form 'sel_entry in pt' is deprecated.  Use 'sel_entry in pt.selected_rows' instead"

        warnings.warn(msg, DeprecationWarning, stacklevel=2)

        # This always gets the selection from the project table order
        # (not directly from the entry order on disk) and then maps the
        # project table row to the project's entry index.  Project table
        # order can differ from the project order (which is what is on
        # the disk) and that's why we do the mapping

        sel_bs = mmproj.mmproj_table_get_selected_rows(self.handle, 1)
        for sel_position in mmbitset.Bitset(sel_bs, manage_handle=False):
            # Convert row index into a project index:
            project_index = \
               mmproj.mmproj_table_get_row_entry_index(self.handle, 1, \
                                                   sel_position)
            yield project_index

    # ***********************************************************************
    #
    # ***********************************************************************
    def refreshTable(self):
        """
        Refresh the project table

        This is only usable from within Maestro.
        """

        if _in_maestro:
            self.update()
        else:
            print("Warning: Project.refreshTable() only works when run ")
            print("from within Maestro")

    # ***********************************************************************
    #
    # ***********************************************************************
    def getPropertyNames(self):
        """
        Return a list of the data names of usable properties in this
        project instance, including properties which were hidden.
        There are some additional properties, like whether an entry is
        selected or included, which are not returned by this function.
        """

        hidden_properties = [
            'b_m_entry_is_selected', 'b_m_entry_has_vis',
            'b_m_entry_in_workspace', 'b_m_entry_has_hypothesis',
            'b_m_entry_has_vibration'
        ]

        ret_list = []
        num_prop = mmproj.mmproj_project_get_property_total(self.handle)
        for i in range(1, num_prop + 1):
            try:
                pname = mmproj.mmproj_property_get_data_name(self.handle, i)
                if pname not in hidden_properties:
                    ret_list.append(pname)
            except mm.MmException:
                pass  # Should we do something here??

        return ret_list

    def getVisiblePropertyNames(self):
        """
        Return a list of the data names of visible properties in this project
        instance (hidden properties are not included).

        :rtype: list(str)
        :return: list of names of the property columns that are currently
                 displayed in the Project Table
        """

        table = 1
        tot_col = mmproj.mmproj_table_get_column_total(self.handle, table)

        ret_list = []
        for col_index in range(1, tot_col + 1):
            is_subset = mmproj.mmproj_table_index_column_get_subset(
                self.handle, table, col_index)
            if is_subset:
                dname = mmproj.mmproj_table_get_column_data_name(
                    self.handle, table, col_index)
                ret_list.append(dname)
        return ret_list

    def getPropertyNamesForSelectedRows(self):
        """
        Return a set of data names of properties (including hidden ones)
        that are common to all selected entries in this project.

        :return: List of property data names
        :rtype: list of str
        """
        # TODO there should be a way to get properties only - PANEL-15258
        sts = (row.getStructure(workspace_sync=False)
               for row in self.selected_rows)
        return analyze.find_common_properties(sts)

    # ***********************************************************************
    #
    # ***********************************************************************
    def _getShortName(self):
        """
        Get the name of the project (without the path)
        """

        pname = mmproj.mmproj_project_get_path(self.handle)
        pname_stripped = os.path.basename(pname)
        return pname_stripped

    # Treat 'shortname' as a Python property
    shortname = property(_getShortName, None, None,
                         "Get the short project name (without the path)")

    # ***********************************************************************
    # fullname - full project path
    # ***********************************************************************
    def _getFullName(self):
        """
        Get the full name of the project (which includes the whole path)
        """
        pname = mmproj.mmproj_project_get_path(self.handle)
        return pname

    fullname = property(_getFullName, None, None,
                        "Get the full name of the project (including the path)")

    # ***********************************************************************
    # additional_data - full project path to additional data
    # ***********************************************************************
    def getAdditionalDataDir(self):
        """
        Get the additional data directory of the project
        """
        return os.path.join(self.fullname, ".mmproj-admin", "additional_data")

    # ***********************************************************************
    # all_rows - dynamic list of all rows in this project (table order)
    # ***********************************************************************
    def _getAllRowsIterator(self):
        return _AllRowsIterator(self)

    all_rows = property(
        _getAllRowsIterator,
        doc=
        "Iterator for all rows in the project using the visible project table order"
    )

    # ***********************************************************************
    # selected_rows - dynamic list of selected rows (table order)
    # ***********************************************************************
    def _getSelectedRowsIterator(self):
        return _SelectedRowsIterator(self)

    selected_rows = property(
        _getSelectedRowsIterator,
        doc=
        "Iterator for the selected rows using the visible project table order")

    # ***********************************************************************
    # included_rows - dynamic list of included rows (undefined order)
    # ***********************************************************************
    def _getIncludedRowsIterator(self):
        return _IncludedRowsIterator(self)

    included_rows = property(
        _getIncludedRowsIterator,
        doc=
        "Iterator for all included rows.  No specific return order for the rows."
    )

    # ***********************************************************************
    # groups:
    # ***********************************************************************
    def _getGroups(self):
        return _EntryGroupIterator(self)

    groups = property(
        _getGroups, None, None, doc="Get the entry groups in the project")

    def createNewGroup(self, title, parent_gid=None, collapsed=False):
        """
        Create a new group with the given title. The EntryGroup object for
        the new group will be returned. Group name/ID will be auto-generated.

        :param title: User-visible title for the new group.
        :type title: string

        :param parent_gid: (Optional) Group ID/name of the parent group. By
            default the new group will be created at top level.
        :type parent_gid: str

        :param collapsed: Whether new group should be collapsed or not.
        :type collapsed: bool

        :return: New group object
        :rtype: EntryGroup
        """

        # Automatically generate a new unique group ID
        i = 1
        while True:
            gid = str(i)
            if not mmproj.mmproj_table_does_group_exist(self.handle, 1, gid):
                break
            i += 1
            assert i < 99999  # just in case

        if parent_gid is None:
            parent_gid = ''
        model = self.project_model.getRowModel()
        status = model.addEntryGroup(gid, title, collapsed, parent_gid)
        assert status == 0

        return self.groups[gid]

    # ***********************************************************************
    # Last added entry
    # ***********************************************************************
    def _getLastAddedEntry(self):

        num_entries = mmproj.mmproj_project_get_entry_total(self.handle)

        return ProjectRow(self, num_entries)

    last_added_entry = property(
        _getLastAddedEntry,
        None,
        None,
        doc=
        "Return a ProjectRow instance for the last entry added to the project")

    #*********************************************************************
    #
    #*********************************************************************
    def freeCachedEntries(self):
        """
        Frees all entries that are cached.

        Things like ProjectRow.getStructure(), ProjectRow.structure,
        ProjectRow.setStructure() will cause entries to be cached.
        Unless the cache is freed memory usage will grow and
        can become quite large (if, for example, you are iterating
        over and retrieving a large number of structures).

        Note that on some operating systems any memory already allocated
        within a process is not returned to the system when it is freed.
        Instead it is simply marked as available to the process and
        only gets returned to the system when the process exits.

        Given this you may not see your memory usage decrease
        after calling this method.   However, by calling this
        method at carefully chosen points you can minimize your memory
        footprint.

        If you free the cache, then the next time the above-mentioned
        methods/properties are invoked they will get and cache the data
        by fetching it from disk.
        """

        for entry_index in self._cached_entries:

            # Should have been cached. So getting it again should be
            # a no-op.
            ct = mmproj.mmproj_index_entry_get_ct(self.handle, entry_index)
            mmproj.mmproj_index_entry_free_ct_and_prop(self.handle, entry_index,
                                                       ct)
        self._cached_entries.clear()

    def importStructure(self,
                        st,
                        name=None,
                        wsreplace=False,
                        copy=True,
                        add_group=False):
        """
        Create a new entry in the Project Table from structure <st> and return
        the ProjectRow object for the new entry.

        In rare cases (when your Structure is managed by C code and use of
        it is no longer needed in python) using copy=False can give better
        performance. Doing so will make the Structure invalid for further
        use in your script.

        :type st: `structure.Structure`
        :param st: Structure to add to the Project Table.

        :type name: str
        :param name: Entry name to give to the new entry. By default, the value
                of the s_m_entry_name property will be used.

        :type wsreplace: bool
        :param wsreplace: whether to replace the Workspace with new entry.
                WARNING: if wsreplace is True, then any scratch entries in the
                Workspace will be disposed of without warning, which is not how
                Maestro would usually behave.

        :type copy: bool
        :param copy: If set to False, will insert the actual input CT into the
                PT instead of copying it first.

        :type add_group: bool
        :param add_group: Whether to create new group(s) based on the
            s_m_subgroup_title and move the new entry to it/them.

        :rtype: ProjectRow
        :return: Return ProjectRow object for the new entry.
        """

        if copy:
            ct_handle = mm.mmct_ct_duplicate(st.handle)
        else:  # copy set to False AND is C-managed
            ct_handle = st.handle

        if name is None:
            name = st.property.get('s_m_entry_name', '')
        entry_index = mmproj.mmproj_project_add_entry(self.handle, name.strip(),
                                                      ct_handle)

        # EV 96284 Project.importStructure() fails to include SEQRES block.
        # Freeing the ct in mmproj will force everything to be read from
        # disk the next time the entry is requested.  In this case the
        # desired m_PDB_SEQRES block will be returned but in the unrequested
        # data handle.
        mmproj.mmproj_index_entry_free_ct_and_prop(self.handle, entry_index,
                                                   ct_handle)

        self.updateCrystalPropertiesIfRequired(entry_index)

        # Entry is imported without Maestro command, so we have to explicitly
        # initiate project table update.
        self.update()
        row = ProjectRow(self, entry_index)
        if wsreplace:
            # Will update the PT partially.
            row.includeOnly()

        if add_group:
            group_titles = st.property.get('s_m_subgroup_title')
            if group_titles is None:
                return
            group = None
            parent_group_id = None
            for group_title in group_titles.split('->'):
                group = self.createNewGroup(group_title, parent_group_id)
                parent_group_id = group.name
            # Move entry to the most inner group:
            row.moveToGroup(group.name)

        return row

    def updateCrystalPropertiesIfRequired(self, entry_index):
        # Attempt to derive crystal properties from desmond
        # properties if exists - MAE-34164.
        # self.project_model = projectmodel.schrodinger.MM_Project(self)
        if projectmodel.mm_convert_and_set_desmond_to_crystal_props(
                self.project_model, entry_index):
            # Delete the properties in project entry.
            projectmodel.mm_entry_delete_crystal_properties(
                self.project_model, entry_index)

    def importStructureFile(self,
                            filename,
                            wsreplace=True,
                            creategroups="multiple",
                            format='any',
                            wsinclude=''):
        """
        Imports all structures from the specified file into the Project Table.
        :param filename: File to import.
        :type filename: str

        :param wsreplace: Whether to replace the Workspace with the first
            structure in the specified file (default True).
        :type wsreplace: bool

        :param creategroups: Which of the imported structures are to be
            grouped. Valid values are "multiple", "all", and "none".
        :type creategroups: str

        :param format: Format of the file. Default is to derive from extension.
        :type format: str

        :param wsinclude: Whether to include all entries in the workspace, or
            first entry or all entries. Valid values are 'none', 'all',
            'first', or empty string (honor maestro preference).
        :type wsinclude: str

        This method is only available when used from within Maestro.
        """
        # TODO: Expose preservegroups option.

        if not _in_maestro:
            print("Warning: Project.importStructureFile() only works when run ")
            print("from within Maestro")
            return

        previous_format = maestro.get_command_option("entryimport", "format")
        previous_wsreplace = \
                          maestro.get_command_option("entryimport",
                                                                 "wsreplace")

        previous_all = maestro.get_command_option("entryimport", "all")

        previous_cg = maestro.get_command_option("entryimport", "creategroups")
        previous_wsinclude = maestro.get_command_option("entryimport",
                                                        "wsinclude")

        cmd = 'entryimport format=%s' % format
        cmd += '\nentryimport creategroups=%s' % creategroups
        cmd += '\nentryimport all=true'
        if wsreplace:
            cmd += '\nentryimport wsreplace=true'
        else:
            cmd += '\nentryimport wsreplace=false'
        if wsinclude:
            cmd += '\nentryimport wsinclude=%s' % wsinclude
        cmd += '\nentryimport "%s"' % filename

        # Reset options to previous values
        cmd += '\nentryimport format=%s wsreplace=%s all=%s creategroups=%s ' \
               'wsinclude=%s' % \
               (previous_format, previous_wsreplace, previous_all, previous_cg,
                previous_wsinclude)

        maestro.command(cmd)

    def exportSelectedEntries(self, filename):
        """
        Export the selected entries to given file.

        :param filename: File to write structures to.
        :type filename: str
        """
        if len(self.selected_rows) == 0:
            raise RuntimeError("No entries are selected in the Project Table.")

        # TODO: grouping information is not preserved. See PYTHON-3122
        if maestro:
            # if not running in unit tests
            maestro.project_table_synchronize()

        st_iterator = (row.getStructure(workspace_sync=False)
                       for row in self.selected_rows)
        structure.write_cts(st_iterator, filename)

    # ***********************************************************************
    #
    # ***********************************************************************
    def getPropertyPrecision(self, property_name):
        """
        Return the precision of the property.

        :param property_name: is the m2io data name (the long name)
                              of the property for which you
                              want the precision
        :type property_name: string
        :return: precision of the property as an integer

        Throws a ValueError if the property name isn't valid.

        """

        try:
            column = mmproj.mmproj_table_get_property_column(
                self.handle, 1, property_name)

        except mm.MmException:
            raise ValueError("Could not get precision - unknown property \'%s\'"
                             % property_name)

        # Only reals have precision
        index = mmproj.mmproj_project_get_property_index(
            self.handle, property_name)
        data_type = mmproj.mmproj_property_get_data_type(self.handle, index)
        if data_type != mm.M2IO_REAL_TYPE:
            raise TypeError("Property must be of type real.  %s is not a real"\
                            % property_name)

        precision = mmproj.mmproj_table_get_column_display_precision(
            self.handle, 1, column)
        return precision

    def isColumnFixed(self, column):
        """
        Return whether the column is in fixed area of the Project Table.

        :type column: int
        :param column: Project column

        :rtype: bool
        :return: Whether the column is in the fixed area.
        """
        _, result = self.project_model.isColumnFixed(column)

        return result

    def getFixedColumnsCount(self, in_subset=True):
        """
        Return number of columns in fixed area. This does not include
        always fixed columns (Row, Included, Stars, 2D Structure and Title).

        :type in_subset: bool
        :param column: Whether to return fixed columns count only for
                       the columns in subset (True by default)

        :rtype: int
        :return: Number of columns in the fixed area.
        """
        _, result = self.project_model.getFixedColumnsCount(in_subset)

        return result

    def sortEntryGroups(self,
                        sort_only_selected_groups,
                        parent_group_id,
                        sort_groups_using,
                        sort_fields_list=None,
                        sort_group_fields_list=None,
                        is_alphanumeric_sort=True,
                        is_ascending_sort=True):
        """
        Sorts the groups in project table based on the sort_groups_using which
        has MM_OPTION_TABLE_SORT_GROUP_OPTION values.

        :type sort_only_selected_groups: bool
        :param sort_only_selected_groups: if true then only groups with
                                          selection will be sorted,
                                          otherwise all groups will be sorted.

        :type parent_group_id: str
        :param parent_group_id:  parent of groups to be sorted.

        :type sort_groups_using: int
        :param sort_groups_using: option value of
                                  MM_OPTION_TABLE_SORT_GROUP_OPTION based on
                                  which groups will be sorted.

        :type sort_fields_list: list of tuples
        :param sort_fields_list: list of tuples having property name and sort
                                 order (ASCENDING OR DESCENDING), together making
                                 sort fields of entries.
                                 e.g.[("Entry Name",ASCENDING),('Stars',DESCENDING)]

        :type sort_group_fields_list: list of tuples
        :param sort_group_fields_list: list of tuples having property name and sort
                                       order (ASCENDING OR DESCENDING), together making
                                       sort fields of groups.
                                       e.g.[("Entry Name",ASCENDING),('Stars',DESCENDING)]

        :type is_alphanumeric_sort: bool
        :param is_alphanumeric_sort: whether strings should be sorted by
                                     treating sequences of digits as single
                                     numbers or string values will be compared
                                     using strcmp for standard sorting.

        :type is_ascending_sort: bool
        :param is_ascending_sort: whether sort values in ascending or
                                  descending order.It is not required if groups
                                  are sorted based on given fields entry values
                                  as then sort fields will have their own sort
                                  order.
        """

        return self.project_model.sortHPTGroups(
            sort_only_selected_groups, parent_group_id, sort_groups_using,
            convertToMMSortFieldsList(sort_fields_list),
            convertToMMSortFieldsList(sort_group_fields_list),
            is_alphanumeric_sort, is_ascending_sort)

    def sortEntries(self,
                    sort_selected_entries,
                    sort_fields_list,
                    blank_cell_sort=projectmodel.BlankCellSort.BOTTOM,
                    is_alphanumeric_sort=True):
        """
        Sorts HPT entries based on given table_sort_fields

        :type sort_selected_entries: bool
        :param sort_selected_entries: if true then only selected entries will be
         sorted,otherwise all entries will be sorted.

        :type sort_fields_list: list of tuples
        :param sort_fields_list: list of tuples having property name and sort
         order (ASCENDING OR DESCENDING), together making sort fields of
         entries.e.g.[("Entry Name",ASCENDING),('Stars',DESCENDING)]

        :type blank_cell_sort: enum projectmodel.BlankCellSort
        :param blank_cell_sort: value of enum projectmodel.BlankCellSort that
         tells how blank cells should be sorted.
         BlankCellSort.TOP - sort so that blank cells are at top,
         BlankCellSort.BOTTOM - sort such that blank cells are at bottom,
         BlankCellSort.LOWEST - sort as if blank cells contain
         lowest possible value
         BlankCellSort.HIGHEST - sort as if blank cells contain highest
         possible value.

        :type is_alphanumeric_sort: bool
        :param is_alphanumeric_sort: whether strings should be sorted by
         treating sequences of digits as single numbers or string values
         will be compared using strcmp for standard sorting.
        """
        return self.project_model.sortEntries(
            sort_selected_entries, convertToMMSortFieldsList(sort_fields_list),
            blank_cell_sort, is_alphanumeric_sort)

    def isCurrentMaestroProject(self):
        """
        Return True if this project is the current project for this
        Maestro session. Returns False if running outside of Maestro.
        """
        if not _in_maestro:
            return False

        return (self.handle == maestro.project_get())


@contextmanager
def open_mmzip(prjzip, mmzip_mode, prjname=None):
    """
    Initializes the mmzip module and opens a file in mmzip. Checks for errors.
    :param prjzip: path to zipped project (or path to where the new .prjzip should go)
    :type prjzip: str

    :param mmzip_mode: mode for mmzip (MMZIP_WRITE or MMZIP_READ)

    :param prjname: path to prj. Optional, because unzip_project does not
                    have a path to a project yet.
    :type prjname: str

    :return: handle to mmzip proj
    """

    # Copies prjzip into prjname (for unzip_project)
    if prjname is None:
        prjname = (os.path.abspath(prjzip))

    mm.mmzip_initialize(mm.error_handler)
    if not mmproj.mmproj_is_project(prjname):
        raise ProjectException("%s is not a valid project" % prjname)

    # Get handle to zip file
    try:
        mmzip_handle = mm.mmzip_open(prjzip, mmzip_mode)
    except mm.MmException:
        mm.mmzip_terminate()
        raise ProjectException(
            "Failed to open/create zipped project %s" % prjzip)
    yield mmzip_handle
    mm.mmzip_close(mmzip_handle)
    mm.mmzip_terminate()


def unzip_project(prjzip, newdir=None):
    """
    Unzip a prjzip. newdir specifies directory into which the new project
    will be created. It will be created with the same name as the old project.

    :param prjzip: path to zipped project
    :type prjzip: str

    :param newdir: destination directory of new unzipped project. This does not
        include the name of the prjdir. If None, unzip into temporary directory under
        SCHRODINGER_TMP.
    :type newdir: str

    :rtype: str
    :return: path of unzipped project

    """

    if newdir is None:
        user_tmp = mm.mmfile_get_schrodinger_temp()
        newdir = tempfile.mkdtemp(prefix='tproj', dir=user_tmp)
    with open_mmzip(prjzip, mm.MMZIP_READ) as mmzip_handle:

        archive_name = None
        # Loop over each file in the zip and extract it
        for file_no in range(mm.mmzip_num_files(mmzip_handle)):
            name = mm.mmzip_file_name(mmzip_handle, file_no)
            if archive_name is None:
                # The first "file" is the top level directory of the
                # project.
                archive_name = name
            mm.mmzip_extract_file_to_dir(mmzip_handle, name, newdir)

    projdir = os.path.join(newdir, archive_name)
    return projdir


def zip_project(prjname, newdir):
    """
   Zip a .prj file. newdir specifies directory into which the zipped file will go.
   It will be created with the same name as the input project.

   :param prjname: path to project
   :type prjname: str

   :param newdir: destination directory for zipped project.
   :type newdir: str

   :return: path of zipped project
   :rtype: str
   """

    # Normalize project path name to remove any trailing path separators.
    # Otherwise os.path.split call below will not work correctly.
    prjname = os.path.normpath(prjname)

    #The add_directory_recursively call takes in the entire file path of a file, starting at
    #the user's home directory, if the full path is given. Because of this, it would zip a small
    #section of the home directory and all the folders that lead up to the desired files instead
    #of just the files themselves. E.g. instead of testpt2.prj being zipped, the folder in the
    #archive would be /Users/yourname/Documents/Schrodinger/testpt2.prj. To avoid this, we CD
    #to the directory of the project and pass in just the project itself, to avoid passing a
    #file path.
    old_cwd = os.getcwd()
    direc, prj = os.path.split(prjname)
    if direc:
        os.chdir(direc)

    # Strip the name of the project file and add zip
    prjzip = prj + "zip"
    # Add the zip file to the intended path
    projdir = os.path.join(newdir, prjzip)
    if not os.path.exists(newdir):
        raise Exception("Chosen directory %s does not exist." % newdir)
    with open_mmzip(projdir, mm.MMZIP_WRITE, prjname) as mmzip_handle:
        # Add project directory to newly created archive
        mm.mmzip_add_directory_recursively(mmzip_handle, prj)

    #CD back to old directory
    os.chdir(old_cwd)

    return projdir


def open_project(projdir, widget=None, autounlock=False, askunlock=True):
    """
    Open a project file, unlocking it if it is locked and the users chooses to.
    open_project is a convenience function for constructing a Project()
    that provides information and options to users when projects are locked.

    If projdir points to a zip archive of a project, we unzip it into a temp
    directory before opening it.

    Note that this routine interacts with the user if the project is locked.

    When closing a project that was extracted from a zip file, the project
    should first be closed, and then the temp directory deleted.  Closing the
    project causes it to delete some of its own subdirectories, and this will
    create an error if the temp directory has already been deleted.  First close
    the project and then use delete_temp_project_directory to do this safely.

    :type projdir: str
    :param projdir: The path to a project

    :type widget: QWidget or None
    :param widget: The parent widget that any informational dialogs should be
        modal to.  Use None if running without a GUI - any messages/input
        requests will go to the terminal.  Note that using None when running
        under a PyQt gui will cause any questions to be asked in the terminal
        and "QCoreApplication::exec: The event loop is already running" to
        print to the terminal if the user is asked whether to unlock a file or
        not. The code runs correctly, however.

    :type autounlock: bool
    :param autounlock: If True, any project will be automatically unlocked and
        opened if it is locked, if False (default), the state of askunlock
        determines behavior.

    Caution - use of autounlock=True can leave the project in an uncertain
    state if it is in use elsewhere, and should only be used if it is essential
    not to interact with the user.

    :type askunlock: bool
    :param askunlock: This parameter is overridden if autounlock is True.
        If askunlock is True (and autounlock is False), will ask the user if a
        locked project should be unlocked.  If False, locked projects will not
        be opened.

    :rtype: schrodinger.project.Project, str, str
    :return: tuple of (project, path to project, path to temp directory). If
            project could not be opened, project will be None. If supplied
            project was a zip archive, path to temporary directory that was
            created to hold project.

    Note that a Project object of a valid but empty project evaluates to False,
    so the way to check if a valid project was returned is to check::

        if project is not None:
            pass

    """

    handle = None
    tempdir = None

    if projdir.endswith('zip'):
        projdir = unzip_project(projdir)
        tempdir = os.path.dirname(projdir)

    if mmproj.mmproj_project_is_locked(projdir):
        # The project is locked.
        if not autounlock and not askunlock:
            return handle, projdir, tempdir

        if autounlock:
            mmproj.mmproj_project_force_unlock(projdir)
        else:
            # The bulk of this except statement is
            # just building a sensible message for the user to let them know
            # who/what has locked this project so they can decide whether to
            # override the lock or not.
            lock_file = mm.m2io_open_file(
                os.path.join(projdir, '.mmproj-admin/lock'), mm.M2IO_READ)
            # Move down the file to the data block of interest
            mm.m2io_goto_block(lock_file, 'f_m_mmproj_lock_file', 1)
            # Grab the list of data labels so we can verify the existance of
            # the ones we are interested in.
            labels = mm.m2io_get_data_names(lock_file, mm.M2IO_ALL_TYPES)

            def get_s_data(astring):
                # Gets string data associated with the label astring
                if astring in labels:
                    try:
                        val = mm.m2io_get_string(lock_file, [astring])[0]
                        if val == 'localhost.localdomain':
                            val = 'the local host'
                        return val
                    except (mm.MmException, IndexError):
                        return ""
                else:
                    return ""

            def get_i_data(astring):
                # Gets integer data associated with the label astring
                if astring in labels:
                    try:
                        return str(mm.m2io_get_int(lock_file, [astring])[0])
                    except (mm.MmException, IndexError):
                        return ""
                else:
                    return ""

            # Display a warning message to the user and ask if it is OK to
            # unlock the project
            lockinfo = "".join([
                'This project is currently locked by user ',
                get_s_data('s_m_username'), ' with user ID ',
                get_i_data('i_m_uid'), '.\nThe locking process has PID ',
                get_i_data('i_m_pid'), ' and is running on', ' ',
                get_s_data('s_m_hostname'), '.'
            ])
            mm.m2io_close_file(lock_file)
            lock_message = "".join([
                '\nIf this project is not really in use',
                ' you may safely remove the lock.\n\n',
                'Do you want to remove the lock?'
            ])
            if widget is not None:
                # Only import PyQt-dependent modules if we are already under a
                # gui (widget != None)
                import schrodinger.ui.qt.appframework as appframework
                doit = appframework.question(
                    '\n'.join([lockinfo, lock_message]),
                    button1='Yes',
                    button2='No',
                    title='Locked Project',
                    parent=widget)
            else:
                prompt = '(yes/no)'
                answer = input('\n'.join([lockinfo, lock_message, prompt]))
                doit = answer.lower() == 'yes' or answer.lower() == 'y'
            if doit:
                # Unlock the project if the user says it is OK to
                mmproj.mmproj_project_force_unlock(projdir)
            else:
                return handle, projdir, tempdir
    try:
        # Open the project
        handle = Project(project_name=projdir)
    except Exception as msg:
        if widget is not None:
            # Only import PyQt-dependent modules if we are already under a
            # gui (widget != None).  We only need to point the user to the
            # terminal if we are running a gui.
            from schrodinger.Qt.QtWidgets import QMessageBox
            QMessageBox.warning(
                widget, 'Warning',
                str(msg) + '\nPlease check the terminal for more information')
    return handle, projdir, tempdir


@contextmanager
def temp_unzip_project(project_filename):
    """
    Yields a Project instance which is suitable for read-only modifications.
    This will open a Project in a temporary directory and delete the temporary
    directory when finished.

    :param project_filename: name of a prjzip file
    :type project_filename: str


    """
    unzipped_project = unzip_project(project_filename)
    try:
        proj = Project(project_name=unzipped_project)
        yield proj
    finally:
        proj.close()
        shutil.rmtree(unzipped_project)


def delete_temp_project_directory(projdir, tempdir, tries=10, force=False):
    """
    Called AFTER closing a project to safely delete the temp directory it
    resides in.

    The project needs access to one of its subdirectories for a few ticks after
    it closes, so this routine waits until that directory disappears before
    deleting the entire directory tree.  Otherwise, exceptions will result.

    Note that after tries * 0.1 seconds, the routine will exit without removing
    the directory to avoid an infinite wait for a project that isn't closing
    properly.  The default yields a maximum wait of 1 second.

    :type projdir: str
    :param projdir: path to the project directory

    :type tempdir: str
    :param tempdir: path to the temp directory to be removed (this is normally
        the parent directory of the project directory

    :type tries: int
    :param tries: The number of times to check if the project has finished
        closing.  Once tries attempts have been made, the routine exists
        without removing the temp directory unless force=True

    :type force: bool
    :param force: If True, then the directory is removed even if the project
        has not yet closed properly - this can lead to exceptions being printed
        to the terminal, though no harm is actually done.  If False (defualt),
        then the temp directory is left intact if the project hasn't closed
        after tries checks.
    """

    count = 0
    check_dir = os.path.join(projdir, '.mmproj-cleanup')
    while os.path.exists(check_dir) and count < tries:
        time.sleep(0.1)
        count = count + 1
    if count < tries or force:
        shutil.rmtree(tempdir)


def convertToMMSortFieldsList(fields_list):
    """
    Convert given tuples list to MM_SortField list.

    :type fields_list: list of tuples
    :param fields_list: list of tuples having property name and sort order.
                        e.g. [("Entry ID",ASCENDING),('Stars',DESCENDING)]
    """
    mmsort_fields = []
    if fields_list is not None:
        for prop_name, sort_order in fields_list:
            mmsort_fields.append(
                projectmodel.MM_SortField(prop_name, sort_order == ASCENDING))
    return mmsort_fields


ProjectException = projectmodel.ProjectException


class LockedProjectException(ProjectException):
    pass


class InvalidProjectException(ProjectException):
    pass


class ArchivedProjectException(ProjectException):
    pass


class InvalidProjectVersion(ProjectException):
    pass


# *** Other methods/actions/features to possibly add:
# - export selected rows PYTHON-3122
# - delete property column

#EOF