[Zope-dev] ExternalEditor & Windows

Casey Duncan casey@zope.com
Tue, 21 May 2002 13:19:34 -0400


--------------Boundary-00=_MG1HH50FIA6XRWF345KC
Content-Type: text/plain;
  charset="iso-8859-1"
Content-Transfer-Encoding: quoted-printable

Gabriel Genellina <gagenellina@softlab.com.ar> just today submitted a por=
t=20
that looks like it goes a long way toward Windows support. Attached is th=
e=20
helper app with her revisions.

Let me know if it works for you.

-Casey

On Tuesday 21 May 2002 01:08 pm, Andy McKay wrote:
> On May 21, 2002 09:29 am, brian.r.brinegar.1 wrote:
> > Hello,
> >
> > I'm working on porting Casey Duncans ExternalEditor helper applicatio=
n to
> > work with Windows. I've got some stuff working, but it's far from
> > complete. I was wondering if anyone else was working on this? I would=
 hate
> > to be duplicating efforts, but I would love to help.
>=20
> I was thinking of doing the same thing, but if you've done most of it..=
=2E=20
> anything I can do to help, let me know.
> --=20
>   Andy McKay

--------------Boundary-00=_MG1HH50FIA6XRWF345KC
Content-Type: text/x-python;
  charset="iso-8859-1";
  name="zopeedit-win32.py"
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment; filename="zopeedit-win32.py"

#!/usr/local/bin/python
##############################################################################
#
# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
# All Rights Reserved.
# 
# This software is subject to the provisions of the Zope Public License,
# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE.
# 
##############################################################################

# Zope External Editor Helper Application by Casey Duncan
# Adapted for Win32 by Gabriel Genellina <gagenellina@softlab.com.ar>

__version__ = '0.1'

import sys, os, stat
from time import sleep
from ConfigParser import ConfigParser
from httplib import HTTPConnection, HTTPSConnection


class Configuration:
    
    def __init__(self, path):
        # Create/read config file on instantiation
        self.path = path
        if not os.path.exists(path):
            f = open(path, 'w')
            f.write(default_configuration)
            f.close()
        self.changed = 0
        self.config = ConfigParser()
        self.config.readfp(open(path))
        
        
    def __del__(self):
        # Save changes on destruction
        if self.changed:
            self.save()
            
    def save(self):
        """Save config options to disk"""
        self.config.write(open(self.path, 'w'))
        self.changed = 0
        
            
    def set(self, section, option, value):
        self.config.set(section, option, value)
        self.changed = 1
    
    def __getattr__(self, name):
        # Delegate to the ConfigParser instance
        return getattr(self.config, name)
        
    def getAllOptions(self, meta_type, content_type):
        """Return a dict of all applicable options for the
           given meta_type and content_type
        """
        opt = {}
        sep = content_type.find('/')
        general_type = '%s/*' % content_type[:sep]
        sections = ('general', 
                    'meta-type:%s' % meta_type,
                    'content-type:%s' % general_type,
                    'content-type:%s' % content_type)
        for section in sections:
            if self.config.has_section(section):
                for option in self.config.options(section):
                    opt[option] = self.config.get(section, option)
        return opt
                     
        
class ExternalEditor:
    
    saved = 1
    
    def __init__(self, input_file):
        try:
            # Read the configuration file
            config_path = os.path.expanduser('~/.zope-external-edit')
            self.config = Configuration(config_path)

            # Open the input file and read the metadata headers
            in_f = open(input_file, 'rb')
            metadata = {}

            while 1:
                line = in_f.readline()[:-1]
                if not line: break
                sep = line.find(':')
                key = line[:sep]
                val = line[sep+1:]
                metadata[key] = val
            self.metadata = metadata

            self.options = self.config.getAllOptions(metadata['meta_type'],
                               metadata.get('content_type',''))

            # Write the body of the input file to a separate file
            if sys.platform == 'win32':
                import random
                import tempfile
                tempfile.mktemp() # init module
                from urllib import unquote

                body_file = unquote(self.metadata['url'][7:])
                p = body_file.rfind('/')
                if p>0: body_file=body_file[p+1:]
                ext = self.options.get('extension')
                if ext and not body_file.endswith(ext):
                    body_file = body_file + ext
                body_file_full = os.path.join(tempfile.tempdir, body_file)
                ok = not os.access(body_file_full, os.F_OK)
                i = 10
                while (not ok) and (i>0):
                    body_file_full = os.path.join(tempfile.tempdir, str(random.random())[2:] + '-' + body_file)
                    ok = not os.access(body_file_full, os.F_OK)
                    i -= 1
                if not ok: 
                    fatalError("Can't create temp file '%s'" % body_file_full)
                body_file = body_file_full
            else:
                body_file = self.metadata['url'][7:].replace('/',',')
                body_file = '%s-%s' % (os.tmpnam(), body_file)
                ext = self.options.get('extension')
                if ext and not body_file.endswith(ext):
                    body_file = body_file + ext

            body_f = open(body_file, 'wb')
            body_f.write(in_f.read())
            in_f.close()
            body_f.close()
            self.clean_up = int(self.options.get('cleanup_files', 1))
            if self.clean_up: os.remove(input_file)
            self.body_file = body_file
        except:
            # for security, always delete the input file even if
            # a fatal error occurs, unless explicitly stated otherwise
            # in the config file
            if getattr(self, 'clean_up', 1):
                os.remove(input_file)
            raise
        
        # Get the host and path from the URL
        self.ssl = self.metadata['url'].startswith('https:')
        url = self.metadata['url'][7:]
        path_start = url.find('/')
        if path_start == -1:
            self.host = url
            self.path = '/'
        else:
            self.host = url[:path_start]
            self.path = url[path_start:]
        
    def __del__(self):
        # for security we always delete the files by default
        if getattr(self, 'clean_up', 1):
            os.remove(self.body_file)
            
    def getEditorPath(self):
        """Return the absolute path to the editor"""
        path = self.options.get('editor')
        if path:
            path = whereIs(path)
        else:
            if sys.platform == 'win32':
                from win32api import FindExecutable,RegOpenKeyEx,RegQueryValueEx
                from win32con import HKEY_CLASSES_ROOT
                ext = self.options.get('extension')
                try:
                    hk = RegOpenKeyEx(HKEY_CLASSES_ROOT, ext)
                    classname, t = RegQueryValueEx(hk, None)
                    try:
                        hk = RegOpenKeyEx(HKEY_CLASSES_ROOT, classname+'\Shell\Edit\Command')
                        path, t = RegQueryValueEx(hk, None)
                    except: pass
                    if not path:
                        try:
                            hk = RegOpenKeyEx(HKEY_CLASSES_ROOT, classname+'\Shell\Open\Command')
                            path, t = RegQueryValueEx(hk, None)
                        except: pass
                except: pass
                if not path:
                    try:
                        ret, path = FindExecutable(self.body_file, '')
                    except: pass

        if (not path) and has_tk():
            from tkSimpleDialog import askstring
            path = askstring('Zope External Editor', 
                             'Enter the default editor name')
            if not path: sys.exit(0)
            path = whereIs(path)
            if os.path.exists(path):
                self.config.set('general', 'editor', path)
                self.config.save()
                
        if not path:
            # Try some sensible default
            if sys.platform == 'win32':
                path = whereIs('notepad.exe')
            else:
                editors = ['xedit', 'gvim', 'emacs']
                while not path:
                    path = whereIs(editors.pop())
                
        if path is not None:            
            return path
        else:
            fatalError('Editor not found at "%s"' % path)
        
    def launch_unix(self):
        """Launch external editor (unix) """
        editor = self.getEditorPath()
        file = self.body_file
        last_fstat = os.stat(file)
        pid = os.spawnl(os.P_NOWAIT, editor, editor, file)
        use_locks = int(self.options.get('use_locks'))
        
        if use_locks:
            self.lock()
            
        exit_pid = 0
        save_interval = self.config.getfloat('general', 'save_interval')
        launched = 0
        
        while exit_pid != pid:
            sleep(save_interval or 2)
            
            try:
                exit_pid, exit_status = os.waitpid(pid, os.WNOHANG)
                if not exit_pid: launched = 1
            except OSError:
                exit_pid = pid
            
            fstat = os.stat(file)
            if (exit_pid == pid or save_interval) \
               and fstat[stat.ST_MTIME] != last_fstat[stat.ST_MTIME]:
                # File was modified
                self.saved = self.putChanges()
                last_fstat = fstat
                
        if not launched:
            fatalError(('Editor "%s" did not launch.\n'
                        'It may not be a graphical editor.') % editor)
         
        if use_locks:
            self.unlock()
        
        if not self.saved and has_tk():
            from tkMessageBox import askyesno
            if askyesno('Zope External Editor',
                        'File not saved to Zope.\nReopen local copy?'):
                has_tk() # ugh, keeps tk happy
                self.launch()
            else:
                self.clean_up = 0 # Keep temp file
                has_tk() # ugh
        

    def launch_win32(self):
        """Launch external editor (win32) """
        from win32event import WaitForSingleObject, WAIT_TIMEOUT, INFINITE, WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_FAILED
        from win32process import CreateProcess, STARTUPINFO
        from win32api import SetConsoleTitle

        file = self.body_file
        title = 'zopeedit %s' % os.path.basename(file) 
        try: SetConsoleTitle(title)
        except: pass
        print title
        editor = self.getEditorPath()
        last_fstat = os.stat(file)
        use_locks = int(self.options.get('use_locks'))
        save_interval = self.config.getfloat('general', 'save_interval')
        editor_wait_forever = self.options.get('editor_wait_forever')
        if editor_wait_forever is not None: editor_wait_forever = int(editor_wait_forever)
        if use_locks:
            self.lock()
        try:
            if editor.find('%1')<0: editor += ' "%1"'
            cmdline = editor.replace('%1', file)
            hProcess, hThread, dwProcessId, dwThreadId = CreateProcess(
                None, cmdline, None, None, 0, 0, None, None, STARTUPINFO())
            launched = hProcess!=0
            if not launched:
                fatalError(('Editor "%s" did not launch.\n'
                        'It may not be a graphical editor.') % editor)
            bExit = 0
            while not bExit:
              try:
                if editor_wait_forever: 
                    sleep(save_interval or 2)    
                    ret = WAIT_TIMEOUT
                else: 
                    ret = WaitForSingleObject(hProcess, int(save_interval*1000) or INFINITE)
                if (ret==WAIT_ABANDONED) : bExit = 1
                elif (ret==WAIT_OBJECT_0): 
                    self.saved = self.putChanges()
                    bExit = 1
                elif (ret==WAIT_TIMEOUT):
                    try:
                        fstat = os.stat(file)
                        if save_interval and (fstat[stat.ST_MTIME] != last_fstat[stat.ST_MTIME]):
                            # File was modified
                            self.saved = self.putChanges()
                            last_fstat = fstat
                    except OSError:
                        bExit = 1    
              except KeyboardInterrupt, SystemExit:
                import msvcrt
                from string import upper
                while 1:
                    print "\nSave, Ignore and continue editing, Abort? ",
                    c = string.upper(msvcrt.getche())
                    if c=='S':
                        self.saved = self.putChanges()
                        bExit = 1
                        break
                    if c=='I':
                        break
                    if c=='A':
                        bExit = 1
                        break
              except:
                print "Oops..."
                bExit = 1
                break

        finally:
            if use_locks:
                self.unlock()
          
        if not self.saved and has_tk():
            from tkMessageBox import askyesno
            if askyesno('Zope External Editor',
                        'File not saved to Zope.\nReopen local copy?'):
                has_tk() # ugh, keeps tk happy
                self.launch()
            else:
                self.clean_up = 0 # Keep temp file
                has_tk() # ugh
        

    def launch(self):
        """Launch external editor """
        if sys.platform == 'win32': self.launch_win32()
        else: self.launch_unix()
        

    def putChanges(self):
        """Save changes to the file back to Zope"""
        f = open(self.body_file, 'rb')
        body = f.read()
        f.close()
        headers = {'Content-Type': 
                   self.metadata.get('content_type', 'text/plain')}
        
        if hasattr(self, 'lock_token'):
            headers['If'] = '<%s> (<%s>)' % (self.path,
                                             self.lock_token)
        
        response = self.zope_request('PUT', headers, body)
        del body # Don't keep the body around longer then we need to

        if response.status / 100 != 2:
            # Something went wrong
            sys.stderr.write('Error occurred during HTTP put:\n%d %s\n' \
                             % (response.status, response.reason))
            sys.stderr.write('\n----\n%s\n----\n' % response.read())
            
            message = response.getheader('Bobo-Exception-Type')
            if has_tk():
                from tkMessageBox import askretrycancel
                if askretrycancel('Zope External Editor',
                                  ('Could not save to Zope.\nError '
                                   'occurred during HTTP put:\n%d %s\n%s') \
                                  % (response.status, response.reason,
                                     message)):
                    has_tk() # ugh, keeps tk happy
                    self.putChanges()
                else:
                    has_tk() # ugh
                    return 0
        return 1
    
    def lock(self):
        """Apply a webdav lock to the object in Zope"""
        
        headers = {'Content-Type':'text/xml; charset="utf-8"',
                   'Timeout':'infinite',
                   'Depth':'infinity',
                  }
        body = ('<?xml version="1.0" encoding="utf-8"?>\n'
                '<d:lockinfo xmlns:d="DAV:">\n'
                '  <d:lockscope><d:exclusive/></d:lockscope>\n'
                '  <d:locktype><d:write/></d:locktype>\n'
                '  <d:depth>infinity</d:depth>\n'
                '  <d:owner>\n' 
                '  <d:href>Zope External Editor</d:href>\n'
                '  </d:owner>\n'
                '</d:lockinfo>'
                )
        
        response = self.zope_request('LOCK', headers, body)
        
        if response.status / 100 == 2:
            # We got our lock, extract the lock token and return it
            reply = response.read()
            token_start = reply.find('>opaquelocktoken:')
            token_end = reply.find('<', token_start)
            if token_start > 0 and token_end > 0:
               self.lock_token = reply[token_start+1:token_end]
        else:
            # We can't lock her sir!
            if response.status == 423:
                message = '\n(Object already locked)'
            else:
                message = response.getheader('Bobo-Exception-Type')

            sys.stderr.write('Error occurred during lock request:\n%d %s\n' \
                             % (response.status, response.reason))
            sys.stderr.write('\n----\n%s\n----\n' % response.read())
            if has_tk():
                from tkMessageBox import askretrycancel
                if askretrycancel('Zope External Editor',
                                  ('Lock request failed:\n%d %s\n%s') \
                                  % (response.status, response.reason, message)):
                    has_tk() # ugh, keeps tk happy
                    return self.lock()
                else:
                    has_tk() # ugh
                    return 0
        return 1
                    
    def unlock(self):
        """Remove webdav lock from edited zope object"""
        if not hasattr(self, 'lock_token'): 
            return 0
            
        headers = {'Lock-Token':self.lock_token}
        
        response = self.zope_request('UNLOCK', headers)
        
        if response.status / 100 != 2:
            # Captain, she's still locked!
            message = response.getheader('Bobo-Exception-Type')
            sys.stderr.write('Error occurred during unlock request:\n%d %s\n%s\n' \
                             % (response.status, response.reason, message))
            sys.stderr.write('\n----\n%s\n----\n' % response.read())
            if has_tk():
                from tkMessageBox import askretrycancel
                if askretrycancel('Zope External Editor',
                                  ('Unlock request failed:\n%d %s') \
                                  % (response.status, response.reason)):
                    has_tk() # ugh, keeps tk happy
                    return self.unlock(token)
                else:
                    has_tk() # ugh
                    return 0
        return 1
        
    def zope_request(self, method, headers={}, body=''):
        """Send a request back to Zope"""
        try:
            if self.ssl:
                h = HTTPSConnection(self.host)
            else:
                h = HTTPConnection(self.host)

            h.putrequest(method, self.path)
            #h.putheader("Host", self.host)  # required by HTTP/1.1
            h.putheader('User-Agent', 'Zope External Editor/%s' % __version__)
            h.putheader('Connection', 'close')

            for header, value in headers.items():
                h.putheader(header, value)

            h.putheader("Content-Length", str(len(body)))

            if self.metadata.get('auth','').startswith('Basic'):
                h.putheader("Authorization", self.metadata['auth'])

            if self.metadata.get('cookie'):
                h.putheader("Cookie", self.metadata['cookie'])

            h.endheaders()
            h.send(body)
            return h.getresponse()
        except:
            # On error return a null response with error info
            class NullResponse:
                def getheader(n,d): return d
                def read(self): return '(No Response From Server)'
            
            response = NullResponse()
            response.reason = sys.exc_info()[1]
            try:
                response.status, response.reason = response.reason
            except:
                response.status = 0
            return response

def has_tk():
    """Sets up a suitable tk root window if one has not
       already been setup. Returns true if tk is happy,
       false if tk throws an error (like its not available)"""
    if not hasattr(globals(), 'tk_root'):
        if sys.platform == 'win32': return 0    # mmm, tk doesnt appear to work here, but maybe a misconfiguration...
        # create a hidden root window to make Tk happy
        try:
            global tk_root
            from Tkinter import Tk
            tk_root = Tk()
            tk_root.withdraw()
            return 1
        except:
            return 0
    return 1
        
def fatalError(message, exit=1):
    """Show error message and exit"""
    message = 'FATAL ERROR:\n%s\n' % message
    try:
        if has_tk():
            from tkMessageBox import showerror
            showerror('Zope External Editor', message)
            has_tk()
    finally:
        sys.stderr.write(message)
        if exit: sys.exit(0)

def whereIs(filename): 
    """Given a filename, returns the full path to it based
       on the PATH environment variable. If no file was found, None is returned
    """
    pathExists = os.path.exists
    
    if pathExists(filename):
        return filename
        
    pathJoin = os.path.join
    paths = os.environ['PATH'].split(os.pathsep)
    
    for path in paths:
        file_path = pathJoin(path, filename)
        if pathExists(file_path):
            return file_path

default_configuration = """\
# Zope External Editor helper application configuration

[general]
# General configuration options

# Uncomment and specify an editor value to override the editor
# specified in the environment
#editor=

# Automatic save interval, in seconds. Set to zero for
# no auto save (save to Zope only on exit).
save_interval = 1

# Temporary file cleanup. Set to false for debugging or
# to waste disk space. Note: setting this to false is a
# security risk to the zope server
cleanup_files = 1

# Use WebDAV locking to prevent concurrent editing by
# different users. Disable for single user use for
# better performance
use_locks = 1

# Ugh... Some editors launch a second process at startup and then kill the first one,
# so when the original process dies that doesnt mean that it has finished working with the file.
# Setting editor_wait_forever = 1 instructs the application to keep monitoring the file 
# even after the original process has died. But the file remains locked, so you can:
#  a) set use_locks = 0 (good for single-user)
#  b) set use_locks = 1 and remember to kill zopeedit.py when editing is done.
#  c) use another editor :)
# This can be set here or in a more specific way, just for the programs that really need it
#editor_wait_forever = 1

# Specific settings by content-type or meta-type. Specific
# settings override general options above. Content-type settings
# override meta-type settings for the same option.

[meta-type:DTML Document]
extension=.dtml

[meta-type:DTML Method]
extension=.dtml

[meta-type:Script (Python)]
extension=.py

[meta-type:Page Template]
extension=.pt

[meta-type:Z SQL Method]
extension=.sql

[content-type:text/*]
extension=.txt

[content-type:text/html]
extension=.html

[content-type:text/xml]
extension=.xml

[content-type:image/*]
editor=gimp

[content-type:image/gif]
extension=.gif

[content-type:image/jpeg]
extension=.jpg

[content-type:image/png]
extensign=.png"""

if __name__ == '__main__':
    try:
        ExternalEditor(sys.argv[1]).launch()
    except KeyboardInterrupt:
        pass
    except SystemExit:
        pass
    except:
        fatalError(sys.exc_info()[1],0)
        raise

--------------Boundary-00=_MG1HH50FIA6XRWF345KC--