Source code for advanced_alchemy._listeners

# ruff: noqa: BLE001, C901, PLR0915
"""Application ORM configuration."""

import asyncio
import datetime
import logging
from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast

from sqlalchemy import event
from sqlalchemy.inspection import inspect

from advanced_alchemy.utils.deprecation import warn_deprecation
from advanced_alchemy.utils.sync_tools import is_async_context as _is_async_context_util

if TYPE_CHECKING:
    from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session
    from sqlalchemy.orm import Session, UOWTransaction, scoped_session
    from sqlalchemy.orm.state import InstanceState

    from advanced_alchemy.cache import CacheManager
    from advanced_alchemy.types.file_object import FileObject, FileObjectSessionTracker, StorageRegistry

_active_file_operations: set[asyncio.Task[Any]] = set()
"""Stores active file operations to prevent them from being garbage collected."""
_active_cache_operations: set[asyncio.Task[Any]] = set()
"""Stores active cache invalidation operations to prevent them from being garbage collected."""

_FILE_TRACKER_KEY = "_aa_file_tracker"
_CACHE_TRACKER_KEY = "_aa_cache_tracker"


def get_file_tracker(
    session: "Session",
    create: bool = True,
) -> Optional["FileObjectSessionTracker"]:
    """Get or create a file session tracker for the session.

    The tracker is stored on session.info to ensure proper scoping
    per session instance and avoid ContextVar collisions.

    Args:
        session: The SQLAlchemy session instance.
        create: Whether to create a new tracker if one doesn't exist.

    Returns:
        The file tracker or None if not available.
    """
    from advanced_alchemy.types.file_object import FileObjectSessionTracker

    tracker: Optional[FileObjectSessionTracker] = session.info.get(_FILE_TRACKER_KEY)
    if tracker is None and create:
        raise_on_error = session.info.get("file_object_raise_on_error", True)
        tracker = FileObjectSessionTracker(raise_on_error=raise_on_error)
        session.info[_FILE_TRACKER_KEY] = tracker
    return tracker


logger = logging.getLogger("advanced_alchemy")


def _is_listener_enabled(session: "Session", key: str) -> bool:
    """Check if a listener is enabled for this session.

    Checks session.info, engine execution_options (including sync_engine),
    and session execution_options, in that priority order.

    Args:
        session: The SQLAlchemy session instance.
        key: The key to check (e.g. ``"enable_file_object_listener"`` or ``"enable_cache_listener"``).

    Returns:
        Whether the listener is enabled.
    """
    enable_listener = True  # Enabled by default

    session_info = getattr(session, "info", {})
    if key in session_info:
        return bool(session_info[key])

    options_sources: list[Optional[Union[Callable[[], dict[str, Any]], dict[str, Any]]]] = []
    if session.bind:
        options_sources.append(getattr(session.bind, "execution_options", None))
        sync_engine = getattr(session.bind, "sync_engine", None)
        if sync_engine:
            options_sources.append(getattr(sync_engine, "execution_options", None))
    options_sources.append(getattr(session, "execution_options", None))

    for options_source in options_sources:
        if options_source is None:
            continue

        options: Optional[dict[str, Any]] = None
        if callable(options_source):
            try:
                result = options_source()
                if isinstance(result, dict):  # pyright: ignore
                    options = result
            except Exception as e:
                logger.debug("Error calling execution_options source: %s", e)
        else:
            options = options_source

        if options is not None and key in options:
            enable_listener = bool(options[key])
            break

    return enable_listener


def set_async_context(is_async: bool = True) -> None:  # noqa: ARG001
    """Set the async context flag.

    .. deprecated:: 1.9.0
        This function is no longer needed as listeners are now explicitly sync or async.
    """
    warn_deprecation(
        version="1.9.0",
        deprecated_name="set_async_context",
        kind="function",
        removal_in="2.0.0",
        info="Listeners are now explicitly sync or async, so this context flag is no longer needed.",
    )


def reset_async_context(token: Any) -> None:  # noqa: ARG001
    """Reset the async context flag using the provided token.

    .. deprecated:: 1.9.0
        This function is no longer needed as listeners are now explicitly sync or async.
    """
    warn_deprecation(
        version="1.9.0",
        deprecated_name="reset_async_context",
        kind="function",
        removal_in="2.0.0",
        info="Listeners are now explicitly sync or async, so this context flag is no longer needed.",
    )


def is_async_context() -> bool:
    """Check if we're in an async context.

    .. deprecated:: 1.9.0
        This function is no longer needed as listeners are now explicitly sync or async.
    """
    warn_deprecation(
        version="1.9.0",
        deprecated_name="is_async_context",
        kind="function",
        removal_in="2.0.0",
        alternative="advanced_alchemy.utils.sync_tools.is_async_context",
        info="This function in `_listeners` is deprecated. Use the utility in `sync_tools` or relying on explicit listener classes.",
    )
    return _is_async_context_util()


class FileObjectInspector:
    """Utilities for inspecting FileObject attribute changes."""

    @staticmethod
    def inspect_instance(instance: Any, tracker: "FileObjectSessionTracker") -> None:
        """Inspect an instance for changes in FileObject attributes."""
        from advanced_alchemy.types.file_object import StoredObject

        state = inspect(instance)
        if not state:
            return
        mapper = state.mapper
        if not mapper:
            return

        for attr_name, attr in mapper.column_attrs.items():
            if not isinstance(attr.expression.type, StoredObject):
                continue

            try:
                attr_state = state.attrs[attr_name]
            except KeyError:
                continue

            is_multiple = getattr(attr.expression.type, "multiple", False)
            if not is_multiple:
                FileObjectInspector.handle_single_attribute(attr_state, tracker)
            else:
                FileObjectInspector.handle_multiple_attribute(instance, attr_name, attr_state, tracker)

    @staticmethod
    def handle_single_attribute(attr_state: Any, tracker: "FileObjectSessionTracker") -> None:
        """Handle inspection of a single FileObject attribute."""

        history = attr_state.history
        current_value: Optional[FileObject] = history.added[0] if history.added else None
        original_value: Optional[FileObject] = history.deleted[0] if history.deleted else None

        if current_value:
            pending_content = getattr(current_value, "_pending_source_content", None)
            pending_source_path = getattr(current_value, "_pending_source_path", None)
            if pending_content is not None:
                tracker.add_pending_save(current_value, pending_content)
            elif pending_source_path is not None:
                tracker.add_pending_save(current_value, pending_source_path)

        if original_value and original_value.path:
            tracker.add_pending_delete(original_value)

    @staticmethod
    def handle_multiple_attribute(
        instance: Any,
        attr_name: str,
        attr_state: Any,
        tracker: "FileObjectSessionTracker",
    ) -> None:
        """Handle inspection of multiple FileObject attributes (MutableList)."""
        from advanced_alchemy.types.file_object import FileObject
        from advanced_alchemy.types.mutables import MutableList

        history = attr_state.history
        items_to_delete: set[FileObject] = set()
        items_to_save: dict[FileObject, Any] = {}

        current_list_instance: Optional[MutableList[FileObject]] = getattr(instance, attr_name, None)
        original_list_from_history: Optional[MutableList[FileObject]] = history.deleted[0] if history.deleted else None
        current_list_from_history: Optional[MutableList[FileObject]] = history.added[0] if history.added else None

        # 1. Deletions from Mutations (Primary source: _pending_removed set)
        if isinstance(current_list_instance, MutableList):
            removed_items_internal: set[FileObject] = getattr(
                current_list_instance,
                "_pending_removed",
                set[FileObject](),
            )
            valid_removed_internal = {item for item in removed_items_internal if item and item.path}
            if valid_removed_internal:
                logger.debug(
                    "[Multiple-Mutation] Found %d valid items in internal _pending_removed set.",
                    len(valid_removed_internal),
                )
                items_to_delete.update(valid_removed_internal)

        # 2. Deletions from Replacements (Secondary source: history)
        if original_list_from_history:  # Indicates list replacement
            logger.debug("[Multiple-Replacement] Processing list replacement via history.")
            original_items_set = {item for item in original_list_from_history if item.path}
            current_items_set = (
                {item for item in current_list_from_history if item.path}
                if current_list_from_history
                else set[FileObject]()
            )
            removed_due_to_replacement = original_items_set - current_items_set
            if removed_due_to_replacement:
                logger.debug(
                    "[Multiple-Replacement] Found %d items removed via replacement.",
                    len(removed_due_to_replacement),
                )
                items_to_delete.update(removed_due_to_replacement)

        # 3. Determine items to save
        # Saves from pending appends (Mutation or New)
        if isinstance(current_list_instance, MutableList):
            pending_append = getattr(current_list_instance, "_pending_append", [])
            if pending_append:
                logger.debug("[Multiple-Mutation] Found %d items in _pending_append list.", len(pending_append))
                for item in pending_append:
                    pending_content = getattr(item, "_pending_content", None)
                    pending_source_path = getattr(item, "_pending_source_path", None)
                    if pending_content is not None:
                        items_to_save[item] = pending_content
                    elif pending_source_path is not None:
                        items_to_save[item] = pending_source_path

        # Saves from newly added list items (New Instance or Replacement)
        if current_list_from_history:
            log_prefix = "[Multiple-New]" if not original_list_from_history else "[Multiple-Replacement]"
            logger.debug(
                "%s Checking current list from history (%d items) for pending saves.",
                log_prefix,
                len(current_list_from_history),
            )
            for item in current_list_from_history:
                pending_content = getattr(item, "_pending_source_content", None)
                pending_source_path = getattr(item, "_pending_source_path", None)
                if pending_content is not None and item not in items_to_save:
                    logger.debug("%s Found pending content for %r", log_prefix, item.filename)
                    items_to_save[item] = pending_content
                elif pending_source_path is not None and item not in items_to_save:
                    logger.debug("%s Found pending source path for %r", log_prefix, item.filename)
                    items_to_save[item] = pending_source_path

        # 4. Finalize MutableList state (if applicable)
        if isinstance(current_list_instance, MutableList):
            finalize_method = getattr(current_list_instance, "_finalize_pending", None)
            if finalize_method:
                logger.debug("[Multiple] Calling _finalize_pending on list instance.")
                finalize_method()

        # 5. Schedule all collected operations
        if items_to_delete:
            logger.debug("[Multiple] Scheduling %d items for deletion.", len(items_to_delete))
            for item_to_delete in items_to_delete:
                tracker.add_pending_delete(item_to_delete)

        if items_to_save:
            logger.debug("[Multiple] Scheduling %d items for saving.", len(items_to_save))
            for item_to_save, data in items_to_save.items():
                tracker.add_pending_save(item_to_save, data)

    @staticmethod
    def process_deleted_instance(
        instance: Any,
        mapper: Any,
        tracker: "FileObjectSessionTracker",
    ) -> None:
        """Process an instance that is being deleted from the session."""
        from advanced_alchemy.types.file_object import StoredObject
        from advanced_alchemy.types.mutables import MutableList

        for attr_name, attr in mapper.column_attrs.items():
            if isinstance(attr.expression.type, StoredObject):
                is_multiple = getattr(attr.expression.type, "multiple", False)
                original_value: Any = getattr(instance, attr_name, None)
                if original_value is None:
                    continue

                if not is_multiple:
                    tracker.add_pending_delete(original_value)
                elif isinstance(original_value, (list, MutableList)):
                    for item in original_value:  # pyright: ignore
                        tracker.add_pending_delete(cast("FileObject", item))


class BaseFileObjectListener:
    """Base class for FileObject event listeners."""

    @classmethod
    def _is_listener_enabled(cls, session: "Session") -> bool:
        return _is_listener_enabled(session, "enable_file_object_listener")

    @classmethod
    def before_flush(cls, session: "Session", flush_context: "UOWTransaction", instances: Optional[object]) -> None:
        """Track FileObject changes before a flush."""
        from advanced_alchemy.types.file_object import StoredObject

        if not cls._is_listener_enabled(session):
            return

        tracker = get_file_tracker(session, create=True)
        if not tracker:
            return

        for instance in session.new:
            FileObjectInspector.inspect_instance(instance, tracker)

        for instance in session.dirty:
            FileObjectInspector.inspect_instance(instance, tracker)

        for instance in session.deleted:
            state = inspect(instance)
            if not state:
                continue
            mapper = state.mapper
            if not mapper:
                continue

            # Avoid inspecting if no StoredObject columns exist
            has_stored_object = any(
                isinstance(attr.expression.type, StoredObject) for attr in mapper.column_attrs.values()
            )
            if not has_stored_object:
                continue

            FileObjectInspector.process_deleted_instance(instance, mapper, tracker)


class SyncFileObjectListener(BaseFileObjectListener):
    """Synchronous FileObject listener."""

    @classmethod
    def after_commit(cls, session: "Session") -> None:
        """Process file operations after a successful commit."""
        tracker = get_file_tracker(session, create=False)
        if tracker:
            tracker.commit()
            session.info.pop(_FILE_TRACKER_KEY, None)

    @classmethod
    def after_rollback(cls, session: "Session") -> None:
        """Clean up pending file operations after a rollback."""
        tracker = get_file_tracker(session, create=False)
        if tracker:
            tracker.rollback()
            session.info.pop(_FILE_TRACKER_KEY, None)


class AsyncFileObjectListener(BaseFileObjectListener):
    """Asynchronous FileObject listener."""

    @classmethod
    def after_commit(cls, session: "Session") -> None:
        """Process file operations after a successful commit."""
        tracker = get_file_tracker(session, create=False)
        if not tracker:
            return

        async def _do_async_commit() -> None:
            try:
                await tracker.commit_async()
            except Exception as e:
                # Using %s for cleaner logging of exception causes
                logger.debug("An error occurred while committing a file object: %s", e.__cause__)
            finally:
                session.info.pop(_FILE_TRACKER_KEY, None)

        # Store the task reference, even if not awaited here
        t = asyncio.create_task(_do_async_commit())
        _active_file_operations.add(t)
        t.add_done_callback(lambda _: _active_file_operations.remove(t))

    @classmethod
    def after_rollback(cls, session: "Session") -> None:
        """Clean up pending file operations after a rollback."""
        tracker = get_file_tracker(session, create=False)
        if not tracker:
            return

        async def _do_async_rollback() -> None:
            try:
                await tracker.rollback_async()
            except Exception as e:
                logger.debug("An error occurred during async FileObject rollback: %s", e.__cause__)
            finally:
                session.info.pop(_FILE_TRACKER_KEY, None)

        # Store the task reference, even if not awaited here
        t = asyncio.create_task(_do_async_rollback())
        _active_file_operations.add(t)
        t.add_done_callback(lambda _: _active_file_operations.remove(t))


class FileObjectListener(SyncFileObjectListener, AsyncFileObjectListener):
    """Legacy FileObject listener that handles both sync and async via runtime checks.

    .. deprecated:: 1.9.0
        Use :class:`SyncFileObjectListener` or :class:`AsyncFileObjectListener` instead.
    """

    @classmethod
    def after_commit(cls, session: "Session") -> None:
        if is_async_context():
            AsyncFileObjectListener.after_commit(session)
        else:
            SyncFileObjectListener.after_commit(session)

    @classmethod
    def after_rollback(cls, session: "Session") -> None:
        if is_async_context():
            AsyncFileObjectListener.after_rollback(session)
        else:
            SyncFileObjectListener.after_rollback(session)


def setup_file_object_listeners(registry: Optional["StorageRegistry"] = None) -> None:  # noqa: ARG001
    """Registers the FileObject event listeners globally.

    .. deprecated:: 1.9.0
        This function registers listeners globally on the Session class.
        Prefer using scoped listeners via SQLAlchemyConfig.
    """
    from sqlalchemy.event import contains
    from sqlalchemy.ext.asyncio import AsyncSession
    from sqlalchemy.orm import Session

    listeners = {
        "before_flush": FileObjectListener.before_flush,
        "after_commit": FileObjectListener.after_commit,
        "after_rollback": FileObjectListener.after_rollback,
    }

    # Register for sync Session
    for event_name, listener_func in listeners.items():
        if not contains(Session, event_name, listener_func):  # type: ignore[arg-type]
            event.listen(Session, event_name, listener_func)  # type: ignore[arg-type]

    async_listeners_to_register = {
        "after_commit": FileObjectListener.after_commit,
        "after_rollback": FileObjectListener.after_rollback,
    }
    for event_name, listener_func in async_listeners_to_register.items():
        if hasattr(AsyncSession, event_name) and not contains(AsyncSession, event_name, listener_func):
            event.listen(AsyncSession, event_name, listener_func)


class CacheInvalidationTracker:
    """Tracks pending cache invalidations for a session transaction.

    This tracker collects entity invalidations during a transaction and
    processes them only after a successful commit. On rollback, the
    pending invalidations are discarded.

    Note:
        Model version bumps are also deferred to commit to ensure rollbacks
        don't invalidate list caches when no DB change occurred.
    """

    __slots__ = ("_cache_manager", "_pending_invalidations", "_pending_model_bumps")

    def __init__(self, cache_manager: "CacheManager") -> None:
        self._cache_manager = cache_manager
        self._pending_invalidations: list[tuple[str, Any, Optional[str]]] = []
        self._pending_model_bumps: set[str] = set()

    def add_invalidation(self, model_name: str, entity_id: Any, bind_group: Optional[str] = None) -> None:
        """Queue an entity for cache invalidation.

        The actual invalidation and model version bump are deferred until
        commit() is called, ensuring rollbacks don't affect the cache.

        Args:
            model_name: The model/table name.
            entity_id: The entity's primary key value.
            bind_group: Optional routing group for multi-master configurations.
                When provided, only the cache entry for that bind_group is
                invalidated.
        """
        self._pending_invalidations.append((model_name, entity_id, bind_group))
        # Queue model version bump for list query invalidation (deferred to commit)
        self._pending_model_bumps.add(model_name)

    def commit(self) -> None:
        """Process all pending invalidations after successful commit."""
        # First bump model versions for list query invalidation
        for model_name in self._pending_model_bumps:
            self._cache_manager.bump_model_version_sync(model_name)
        self._pending_model_bumps.clear()

        # Then invalidate individual entities
        for model_name, entity_id, bind_group in self._pending_invalidations:
            self._cache_manager.invalidate_entity_sync(model_name, entity_id, bind_group)
        self._pending_invalidations.clear()

    def rollback(self) -> None:
        """Discard pending invalidations on rollback."""
        self._pending_invalidations.clear()
        self._pending_model_bumps.clear()

    async def commit_async(self) -> None:
        """Process all pending invalidations after successful commit (async-safe).

        This method performs cache I/O using the CacheManager async APIs so that
        dogpile backends (often sync network clients) never block the event loop.
        """
        # First bump model versions for list query invalidation
        for model_name in self._pending_model_bumps:
            await self._cache_manager.bump_model_version_async(model_name)
        self._pending_model_bumps.clear()

        # Then invalidate individual entities
        for model_name, entity_id, bind_group in self._pending_invalidations:
            await self._cache_manager.invalidate_entity_async(model_name, entity_id, bind_group)
        self._pending_invalidations.clear()


def get_cache_tracker(
    session: "Union[Session, AsyncSession, scoped_session[Session], async_scoped_session[AsyncSession]]",
    cache_manager: Optional["CacheManager"] = None,
    create: bool = True,
) -> Optional["CacheInvalidationTracker"]:
    """Get or create a cache invalidation tracker for the session.

    The tracker is stored on session.info to ensure proper scoping
    per session instance and avoid ContextVar collisions.

    Args:
        session: The SQLAlchemy session instance (sync or async, including scoped sessions).
        cache_manager: The CacheManager instance (required if create=True).
        create: Whether to create a new tracker if one doesn't exist.

    Returns:
        The cache tracker or None if not available.
    """
    tracker: Optional[CacheInvalidationTracker] = session.info.get(_CACHE_TRACKER_KEY)
    if tracker is None and create and cache_manager is not None:
        tracker = CacheInvalidationTracker(cache_manager)
        session.info[_CACHE_TRACKER_KEY] = tracker
    return tracker


class BaseCacheListener:
    """Base class for cache invalidation event listeners."""

    @classmethod
    def _is_listener_enabled(cls, session: "Session") -> bool:
        """Check if cache listener is enabled for this session."""
        return _is_listener_enabled(session, "enable_cache_listener")


class SyncCacheListener(BaseCacheListener):
    """Synchronous cache invalidation listener."""

    @classmethod
    def after_commit(cls, session: "Session") -> None:
        """Process cache invalidations after a successful commit."""
        if not cls._is_listener_enabled(session):
            return

        tracker = get_cache_tracker(session, create=False)
        if tracker:
            tracker.commit()
            session.info.pop(_CACHE_TRACKER_KEY, None)

    @classmethod
    def after_rollback(cls, session: "Session") -> None:
        """Discard pending cache invalidations after a rollback."""
        tracker = get_cache_tracker(session, create=False)
        if tracker:
            tracker.rollback()
            session.info.pop(_CACHE_TRACKER_KEY, None)


class AsyncCacheListener(BaseCacheListener):
    """Asynchronous cache invalidation listener."""

    @classmethod
    def after_commit(cls, session: "Session") -> None:
        """Process cache invalidations after a successful commit."""
        if not cls._is_listener_enabled(session):
            return

        tracker = get_cache_tracker(session, create=False)
        if tracker:
            task = asyncio.create_task(tracker.commit_async())
            _active_cache_operations.add(task)
            task.add_done_callback(_active_cache_operations.discard)
            session.info.pop(_CACHE_TRACKER_KEY, None)

    @classmethod
    def after_rollback(cls, session: "Session") -> None:
        """Discard pending cache invalidations after a rollback."""
        tracker = get_cache_tracker(session, create=False)
        if tracker:
            tracker.rollback()
            session.info.pop(_CACHE_TRACKER_KEY, None)


class CacheInvalidationListener(BaseCacheListener):
    """Unified cache invalidation listener for sync and async contexts.

    This class preserves the historical behavior of choosing sync vs async
    invalidation based on whether a running event loop is detected.
    """

    @classmethod
    def after_commit(cls, session: "Session") -> None:
        """Process cache invalidations after a successful commit."""
        if not cls._is_listener_enabled(session):
            return

        tracker = get_cache_tracker(session, create=False)
        if tracker:
            try:
                asyncio.get_running_loop()
            except RuntimeError:
                # No running loop: sync usage, perform invalidation inline.
                tracker.commit()
            else:
                # Running loop: schedule async invalidation so commit doesn't block.
                task = asyncio.create_task(tracker.commit_async())
                _active_cache_operations.add(task)
                task.add_done_callback(_active_cache_operations.discard)
            session.info.pop(_CACHE_TRACKER_KEY, None)

    @classmethod
    def after_rollback(cls, session: "Session") -> None:
        """Discard pending cache invalidations after a rollback."""
        tracker = get_cache_tracker(session, create=False)
        if tracker:
            tracker.rollback()
            session.info.pop(_CACHE_TRACKER_KEY, None)


[docs] def setup_cache_listeners() -> None: """Register cache invalidation event listeners globally. This registers the unified listener on Session, which handles both sync and async contexts by detecting a running event loop at commit time. For more control, prefer using scoped listeners via SQLAlchemyConfig. """ from sqlalchemy.event import contains from sqlalchemy.orm import Session # Use the unified listener that handles both sync and async contexts listeners = { "after_commit": CacheInvalidationListener.after_commit, "after_rollback": CacheInvalidationListener.after_rollback, } for event_name, listener_func in listeners.items(): if not contains(Session, event_name, listener_func): event.listen(Session, event_name, listener_func) logger.debug("Cache invalidation listeners registered")
# Existing listener (keep it) def touch_updated_timestamp(session: "Session", *_: Any) -> None: """Set timestamp on update. Called from SQLAlchemy's :meth:`before_flush <sqlalchemy.orm.SessionEvents.before_flush>` event to bump the ``updated`` timestamp on modified instances. Args: session: The sync :class:`Session <sqlalchemy.orm.Session>` instance that underlies the async session. """ for instance in session.dirty: state = inspect(instance) if not state or not hasattr(state.mapper.class_, "updated_at") or state.deleted or instance in session.new: continue updated_at_attr = state.attrs.get("updated_at") if not updated_at_attr or updated_at_attr.history.added: # Respect explicit user assignments such as manual overrides or import routines continue if _has_persistent_column_changes(state, skip_keys={"updated_at"}): instance.updated_at = datetime.datetime.now(datetime.timezone.utc) def _has_persistent_column_changes( state: "InstanceState[Any]", *, skip_keys: "Optional[set[str]]" = None, ) -> bool: """Check if any mapped column (excluding ``skip_keys``) has modifications pending flush.""" if skip_keys is None: skip_keys = set() mapper = state.mapper for attr in mapper.column_attrs: if attr.key in skip_keys: continue attr_state = state.attrs.get(attr.key) if attr_state is not None and attr_state.history.has_changes(): return True return False