#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Monitors a directory tree for changes using the inotify API.

See http://linux.die.net/man/7/inotify.
"""


import ctypes
import ctypes.util
import errno
import itertools
import logging
import os
import select
import struct
import sys

from google.appengine.tools.devappserver2 import watcher_common

IN_MODIFY = 0x00000002
IN_ATTRIB = 0x00000004
IN_MOVED_FROM = 0x00000040
IN_MOVED_TO = 0x00000080
IN_CREATE = 0x00000100
IN_DELETE = 0x00000200

IN_IGNORED = 0x00008000
IN_ISDIR = 0x40000000

_INOTIFY_EVENT = struct.Struct('iIII')
_INOTIFY_EVENT_SIZE = _INOTIFY_EVENT.size
_INTERESTING_INOTIFY_EVENTS = (
    IN_ATTRIB|IN_MODIFY|IN_MOVED_FROM|IN_MOVED_TO|IN_CREATE|IN_DELETE)

# inotify only available on Linux and a ctypes.CDLL will raise if code tries to
# specify the arg types or return type for a non-existent function.
if sys.platform.startswith('linux'):
  _libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True)
  _libc.inotify_init.argtypes = []
  _libc.inotify_init.restype = ctypes.c_int
  _libc.inotify_add_watch.argtypes = [ctypes.c_int,
                                      ctypes.c_char_p,
                                      ctypes.c_uint32]
  _libc.inotify_add_watch.restype = ctypes.c_int
  _libc.inotify_rm_watch.argtypes = [ctypes.c_int,
                                     ctypes.c_int]
  _libc.inotify_rm_watch.restype = ctypes.c_int
else:
  _libc = None


class InotifyFileWatcher(object):
  """Monitors a directory tree for changes using inotify."""

  SUPPORTS_MULTIPLE_DIRECTORIES = True

  def __init__(self, directories):
    """Initializer for InotifyFileWatcher.

    Args:
      directories: An iterable of strings representing the path to a directory
          that should be monitored for changes i.e. files and directories added,
          renamed, deleted or changed.

    Raises:
      OSError: if there are no inotify instances available.
    """
    assert _libc is not None, 'InotifyFileWatcher only available on Linux.'
    self._directories = [os.path.abspath(d) for d in directories]
    self._watch_to_directory = {}
    self._directory_to_watch_descriptor = {}
    self._directory_to_subdirs = {}
    self._inotify_events = ''
    self._inotify_fd = _libc.inotify_init()
    if self._inotify_fd < 0:
      error = OSError('failed call to inotify_init')
      error.errno = ctypes.get_errno()
      error.strerror = errno.errorcode[ctypes.get_errno()]
      raise error
    self._inotify_poll = select.poll()


  def _remove_watch_for_path(self, path):
    logging.debug('_remove_watch_for_path(%r)', path)
    wd = self._directory_to_watch_descriptor[path]

    if _libc.inotify_rm_watch(self._inotify_fd, wd) < 0:
      # If the directory is deleted then the watch will removed automatically
      # and inotify_rm_watch will fail. Just log the error.
      logging.debug('inotify_rm_watch failed for %r: %d [%r]',
                    path,
                    ctypes.get_errno(),
                    errno.errorcode[ctypes.get_errno()])

    parent_path = os.path.dirname(path)
    if parent_path in self._directory_to_subdirs:
      self._directory_to_subdirs[parent_path].remove(path)

    # _directory_to_subdirs must be copied because it is mutated in the
    # recursive call.
    for subdir in frozenset(self._directory_to_subdirs[path]):
      self._remove_watch_for_path(subdir)

    del self._watch_to_directory[wd]
    del self._directory_to_watch_descriptor[path]
    del self._directory_to_subdirs[path]

  def _add_watch_for_path(self, path):
    logging.debug('_add_watch_for_path(%r)', path)

    for dirpath, directories, _ in itertools.chain(
        [(os.path.dirname(path), [os.path.basename(path)], None)],
        os.walk(path, topdown=True, followlinks=True)):
      watcher_common.remove_ignored_dirs(directories)
      for directory in directories:
        directory_path = os.path.join(dirpath, directory)
        # dirpath cannot be used as the parent directory path because it is the
        # empty string for symlinks :-(
        parent_path = os.path.dirname(directory_path)

        watch_descriptor = _libc.inotify_add_watch(
            self._inotify_fd,
            ctypes.create_string_buffer(directory_path),
            _INTERESTING_INOTIFY_EVENTS)
        if watch_descriptor < 0:
          if ctypes.get_errno() == errno.ENOSPC:
            logging.warning(
                'There are too many directories in your application for '
                'changes in all of them to be monitored. You may have to '
                'restart the development server to see some changes to your '
                'files.')
            return
          error = OSError('could not add watch for %r' % directory_path)
          error.errno = ctypes.get_errno()
          error.strerror = errno.errorcode[ctypes.get_errno()]
          error.filename = directory_path
          raise error

        if parent_path in self._directory_to_subdirs:
          self._directory_to_subdirs[parent_path].add(directory_path)
        self._watch_to_directory[watch_descriptor] = directory_path
        self._directory_to_watch_descriptor[directory_path] = watch_descriptor
        self._directory_to_subdirs[directory_path] = set()

  def start(self):
    """Start watching the directory for changes."""
    self._inotify_poll.register(self._inotify_fd, select.POLLIN)
    for directory in self._directories:
      self._add_watch_for_path(directory)

  def quit(self):
    """Stop watching the directory for changes."""
    os.close(self._inotify_fd)

  def _get_changed_paths(self):
    """Return paths for changed files and directories.

    start() must be called before this method.

    Returns:
      A set of strings representing file and directory paths that have changed
      since the last call to get_changed_paths.
    """
    paths = set()
    while True:
      if not self._inotify_poll.poll(0):
        break

      self._inotify_events += os.read(self._inotify_fd, 1024)
      while len(self._inotify_events) > _INOTIFY_EVENT_SIZE:
        wd, mask, cookie, length = _INOTIFY_EVENT.unpack(
            self._inotify_events[:_INOTIFY_EVENT_SIZE])
        if len(self._inotify_events) < _INOTIFY_EVENT_SIZE + length:
          break

        name = self._inotify_events[
            _INOTIFY_EVENT_SIZE:_INOTIFY_EVENT_SIZE+length]
        name = name.rstrip('\0')

        logging.debug('wd=%s, mask=%s, cookie=%s, length=%s, name=%r',
                      wd, hex(mask), cookie, length, name)

        self._inotify_events = self._inotify_events[_INOTIFY_EVENT_SIZE+length:]

        if mask & IN_IGNORED:
          continue
        try:
          directory = self._watch_to_directory[wd]
        except KeyError:
          logging.debug('Watch deleted for watch descriptor=%d', wd)
          continue

        path = os.path.join(directory, name)
        if os.path.isdir(path) or path in self._directory_to_watch_descriptor:
          if mask & IN_DELETE:
            self._remove_watch_for_path(path)
          elif mask & IN_MOVED_FROM:
            self._remove_watch_for_path(path)
          elif mask & IN_CREATE:
            self._add_watch_for_path(path)
          elif mask & IN_MOVED_TO:
            self._add_watch_for_path(path)
        if path not in paths and not watcher_common.ignore_file(path):
          paths.add(path)
    return paths

  def has_changes(self):
    return bool(self._get_changed_paths())
