diff --git a/edbob/commands.py b/edbob/commands.py index 813ee45..00fc31b 100644 --- a/edbob/commands.py +++ b/edbob/commands.py @@ -384,6 +384,45 @@ class DatabaseCommand(Subcommand): # print "Use 'rattail {activate|deactivate} EXTENSION' to change." +class FileMonitorCommand(Subcommand): + """ + Interacts with the file monitor Windows service; called as ``edbob + filemon``. This command expects a subcommand; one of the following: + + * ``edbob filemon install`` + * ``edbob filemon start`` + * ``edbob filemon stop`` + * ``edbob filemon uninstall`` + + .. note:: + The Windows Vista family of operating systems requires you to launch + ``cmd.exe`` as an Administrator in order to have sufficient rights to + run the above commands. + + See :doc:`howto.use_filemon` for more information. + """ + + name = 'filemon' + description = "Manage the file monitor service on Windows" + + def add_parser_args(self, parser): + subparsers = parser.add_subparsers(title='subcommands') + install = subparsers.add_parser('install', + help="Install (register) service") + install.set_defaults(subcommand='install') + uninstall = subparsers.add_parser('uninstall', + help="Uninstall (unregister) service") + uninstall.set_defaults(subcommand='remove') + start = subparsers.add_parser('start', help="Start service") + start.set_defaults(subcommand='start') + stop = subparsers.add_parser('stop', help="Stop service") + stop.set_defaults(subcommand='stop') + + def run(self, args): + from edbob.filemon import exec_server_command + exec_server_command(args.subcommand) + + class ShellCommand(Subcommand): """ Launches a Python shell (of your choice) with ``edbob`` pre-loaded; called diff --git a/edbob/db/__init__.py b/edbob/db/__init__.py index 8779a20..18d6888 100644 --- a/edbob/db/__init__.py +++ b/edbob/db/__init__.py @@ -26,6 +26,8 @@ ``edbob.db`` -- Database Framework """ +from __future__ import absolute_import + from sqlalchemy import engine_from_config, MetaData from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.declarative import declarative_base diff --git a/edbob/filemon.py b/edbob/filemon.py new file mode 100644 index 0000000..38f1d1c --- /dev/null +++ b/edbob/filemon.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# edbob is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with edbob. If not, see . +# +################################################################################ + +""" +``edbob.filemon`` -- File Monitoring Service +""" + +# Much of the Windows monitoring code below was borrowed from Tim Golden: +# http://timgolden.me.uk/python/win32_how_do_i/watch_directory_for_changes.html + +import sys +import os.path +import threading +import logging +import subprocess + +import edbob +from edbob.exceptions import ConfigError + +if sys.platform == 'win32': + import win32file + import win32con + + +FILE_LIST_DIRECTORY = 0x0001 + +ACTION_CREATE = 1 +ACTION_DELETE = 2 +ACTION_UPDATE = 3 +ACTION_RENAME_TO = 4 +ACTION_RENAME_FROM = 5 + +log = logging.getLogger(__name__) + + +def exec_server_command(command): + """ + Executes ``command`` against the file monitor Windows service, i.e. one of: + + * ``'install'`` + * ``'start'`` + * ``'stop'`` + * ``'remove'`` + """ + server_path = os.path.join(os.path.dirname(__file__), 'filemon_server.py') + subprocess.call([sys.executable, server_path, command]) + + +class MonitorProfile(object): + """ + This is a simple profile class, used to represent configuration of the file + monitor service. + """ + + def __init__(self, key): + self.key = key + self.dirs = eval(edbob.config.require('edbob.filemon', '%s.dirs' % key)) + if not self.dirs: + raise ConfigError('edbob.filemon', '%s.dirs' % key) + self.actions = eval(edbob.config.require('edbob.filemon', '%s.actions' % key)) + if not self.actions: + raise ConfigError('edbob.filemon', '%s.actions' % key) + + +def monitor_win32(path, include_subdirs=False): + """ + This is the workhorse of file monitoring on the Windows platform. It is a + generator function which yields a ``(file_type, file_path, action)`` tuple + whenever changes occur in the monitored folder. ``file_type`` will be one + of: + + * ``'file'`` + * ``'folder'`` + * ``''`` + + ``file_path`` will be the path to the changed object; and ``action`` will + be one of: + + * ``ACTION_CREATE`` + * ``ACTION_DELETE`` + * ``ACTION_UPDATE`` + * ``ACTION_RENAME_TO`` + * ``ACTION_RENAME_FROM`` + + (The above are "constants" importable from ``edbob.filemon``.) + + This function leverages the ``ReadDirectoryChangesW()`` Windows API + function. This is nice because the OS now reports changes to us so that we + needn't poll to find them. + + However ``ReadDirectoryChangesW()`` is a blocking call, so in practice this + means that if, say, you're using this function in a python shell, you will + not be able to stop the process with a keyboard interrupt. (Actually you + sort of can, as long as you send the interrupt and then perform some file + operation which will trigger the monitoring function to return.) + + Fortunately the primary need for this function is by the Windows service, + which is of course "disconnected" from the user's desktop and never really + needs to close under normal circumstances. + """ + hDir = win32file.CreateFile( + path, + FILE_LIST_DIRECTORY, + win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE, + None, + win32con.OPEN_EXISTING, + win32con.FILE_FLAG_BACKUP_SEMANTICS, + None) + + while True: + results = win32file.ReadDirectoryChangesW ( + hDir, + 1024, + include_subdirs, + win32con.FILE_NOTIFY_CHANGE_FILE_NAME | + win32con.FILE_NOTIFY_CHANGE_DIR_NAME | + win32con.FILE_NOTIFY_CHANGE_ATTRIBUTES | + win32con.FILE_NOTIFY_CHANGE_SIZE | + win32con.FILE_NOTIFY_CHANGE_LAST_WRITE | + win32con.FILE_NOTIFY_CHANGE_SECURITY, + None, + None) + + for action, fn in results: + fpath = os.path.join(path, fn) + if not os.path.exists(fpath): + ftype = "" + elif os.path.isdir(fpath): + ftype = "folder" + else: + ftype = "file" + yield ftype, fpath, action + + +class WatcherWin32(threading.Thread): + """ + A ``threading.Thread`` subclass which is responsible for monitoring a + particular folder (on Windows platforms). + """ + + def __init__(self, key, path, queue, include_subdirs=False, **kwargs): + threading.Thread.__init__(self, **kwargs) + self.setDaemon(1) + self.key = key + self.path = path + self.queue = queue + self.include_subdirs = include_subdirs + self.start() + + def run(self): + for result in monitor_win32(self.path, + include_subdirs=self.include_subdirs): + self.queue.put((self.key,) + result) diff --git a/edbob/filemon_server.py b/edbob/filemon_server.py new file mode 100644 index 0000000..881c7b1 --- /dev/null +++ b/edbob/filemon_server.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# edbob is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with edbob. If not, see . +# +################################################################################ + +""" +``edbob.filemon_server`` -- File Monitoring Service for Windows +""" + +import os.path +import socket +import time +import Queue + +import edbob +from edbob.win32 import file_is_free +from edbob.filemon import (MonitorProfile, WatcherWin32, + ACTION_CREATE, ACTION_UPDATE) + +import sys +if sys.platform == 'win32': # docs should build for everyone + import win32serviceutil + import win32service + import win32event + import servicemanager + import win32api + + +class FileMonitorService(win32serviceutil.ServiceFramework): + """ + Implements edbob's file monitor Windows service. + """ + + _svc_name_ = "Edbob File Monitor" + _svc_display_name_ = "Edbob : File Monitoring Service" + _svc_description_ = ("Monitors one or more folders for incoming files, " + "and performs configured actions as new files arrive.") + + appname = 'edbob' + + def __init__(self, *args): + win32serviceutil.ServiceFramework.__init__(self, *args) + self.hWaitStop = win32event.CreateEvent(None, 0, 0, None) + socket.setdefaulttimeout(60) + self.stop_requested = False + + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + win32event.SetEvent(self.hWaitStop) + self.stop_requested = True + + def SvcDoRun(self): + servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, '')) + edbob.init(self.appname) + self.main() + + def main(self): + self.monitored = {} + monitored = edbob.config.require('edbob.filemon', 'monitored') + monitored = monitored.split(',') + for m in monitored: + m = m.strip() + self.monitored[m] = MonitorProfile(m) + queue = Queue.Queue() + + for key in self.monitored: + for d in self.monitored[key].dirs: + WatcherWin32(key, d, queue) + + while not self.stop_requested: + try: + key, ftype, fpath, action = queue.get_nowait() + except Queue.Empty: + pass + else: + if ftype == 'file' and action in ( + ACTION_CREATE, ACTION_UPDATE): + self.do_actions(key, fpath) + win32api.SleepEx(250, True) + + servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STOPPED, + (self._svc_name_, '')) + + def do_actions(self, key, path): + if not os.path.exists(path): + return + while not file_is_free(path): + # TODO: Add configurable timeout so long-open files can't hijack + # our prcessing. + time.sleep(0.25) + for action in self.monitored[key].actions: + if isinstance(action, tuple): + func = action[0] + args = action[1:] + else: + func = action + args = [] + func = edbob.load_spec(func) + func(path, *args) + + +if __name__ == '__main__': + win32serviceutil.HandleCommandLine(FileMonitorService) diff --git a/edbob/pyramid/scaffolds/edbob/+package+/filemon.py_tmpl b/edbob/pyramid/scaffolds/edbob/+package+/filemon.py_tmpl new file mode 100644 index 0000000..4683c64 --- /dev/null +++ b/edbob/pyramid/scaffolds/edbob/+package+/filemon.py_tmpl @@ -0,0 +1,23 @@ +#!/usr/bin/env python + +""" +``{{package}}.filemon`` -- Windows File Monitor +""" + +from edbob import filemon_server + + +class FileMonitorService(filemon_server.FileMonitorService): + """ + Implements the {{project}} file monitor Windows service. + """ + + _svc_name_ = "{{project}} File Monitor" + _svc_display_name_ = "{{project}} : File Monitoring Service" + + appname = '{{package}}' + + +if __name__ == '__main__': + import win32serviceutil + win32serviceutil.HandleCommandLine(FileMonitorService) diff --git a/edbob/win32.py b/edbob/win32.py new file mode 100644 index 0000000..20ece0e --- /dev/null +++ b/edbob/win32.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# edbob is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with edbob. If not, see . +# +################################################################################ + +""" +``edbob.win32`` -- Stuff for Microsoft Windows +""" + +import sys +if sys.platform == 'win32': # docs should build for everyone + import win32api + import win32con + import pywintypes + import win32file + import winerror + + +def RegDeleteTree(key, subkey): + """ + This is a clone of ``win32api.RegDeleteTree()``, since that apparently + requires Vista or later. + """ + + def delete_contents(key): + subkeys = [] + for name, reserved, class_, mtime in win32api.RegEnumKeyEx(key): + subkeys.append(name) + for subkey_name in subkeys: + subkey = win32api.RegOpenKeyEx(key, subkey_name, 0, win32con.KEY_ALL_ACCESS) + delete_contents(subkey) + win32api.RegCloseKey(subkey) + win32api.RegDeleteKey(key, subkey_name) + values = [] + i = 0 + while True: + try: + name, value, type_ = win32api.RegEnumValue(key, i) + except pywintypes.error, e: + if e[0] == winerror.ERROR_NO_MORE_ITEMS: + break + values.append(name) + i += 1 + for value in values: + win32api.RegDeleteValue(key, value) + + orig_key = key + try: + key = win32api.RegOpenKeyEx(orig_key, subkey, 0, win32con.KEY_ALL_ACCESS) + except pywintypes.error, e: + if e[0] != winerror.ERROR_FILE_NOT_FOUND: + raise + else: + delete_contents(key) + win32api.RegCloseKey(key) + try: + win32api.RegDeleteKey(orig_key, subkey) + except pywintypes.error, e: + if e[0] == winerror.ERROR_FILE_NOT_FOUND: + pass + + +def file_is_free(path): + """ + Returns boolean indicating whether or not the file located at ``path`` is + currently tied up in any way by another process. + """ + + # This code was borrowed from Nikita Nemkin: + # http://stackoverflow.com/a/2848266 + + handle = None + try: + handle = win32file.CreateFile( + path, + win32file.GENERIC_WRITE, + 0, + None, + win32file.OPEN_EXISTING, + win32file.FILE_ATTRIBUTE_NORMAL, + None) + return True + except pywintypes.error, e: + if e[0] == winerror.ERROR_SHARING_VIOLATION: + return False + raise + finally: + if handle: + win32file.CloseHandle(handle) diff --git a/setup.py b/setup.py index 5fcea22..e65a755 100644 --- a/setup.py +++ b/setup.py @@ -178,6 +178,7 @@ edbob = edbob.pyramid.scaffolds:Template [edbob.commands] db = edbob.commands:DatabaseCommand +filemon = edbob.commands:FileMonitorCommand shell = edbob.commands:ShellCommand uuid = edbob.commands:UuidCommand