File: pyedit-products/unzipped/PP4E/launchmodes.py

#!/usr/bin/env python
"""
###################################################################################
Launch Python programs with command lines and reusable launcher scheme classes.
Auto inserts "python" and/or path to Python executable at front of command line.
Some of this module may assume 'python' is on your system path (see Launcher.py).

subprocess module would work too, but os.popen() uses it internally, and the goal
is to start a program running independently here, not to connect to its streams.
multiprocessing module also is an option, but this is command-lines, not functions:
it makes no sense to start a process which would just do one of the options here.

New in book PP4E: runs script filename path through normpath() to change any
/ to \ for Windows tools where required; fix is inherited by PyEdit and others;
on Windows, / is generally allowed for file opens, but not by all launcher tools;

----
RECENT UPGRADES
New Dec-2016: tweak PYFILE for Unix, and add 'open' commandline option for Mac OS
tha runs as though clicked in Finder, much like a 'start' or startfile() on Windows.

New Mar-2017: "keep"-console mode in Start, variable mode in Spawn, and support  
the 'xdg-open' option for Linux command lines - also runs file as though clicked. 

New Mar-2017: add 'python' arg to override sys.executable with an install path
provided by users, for both frozen executables without one, and IDE flexibility.

Caveat: should probably also have a mode for subprocess.Popen() - which, among
other things, automatically quotes command-line items with spaces on Windows.
Given the MANY options in subprocess, though, a wrapper here may be superfluous.
Ditto for multiprocessing: programs are probably better off using it directly.
[Update: Popen now is used for some modes, to avoid having to split cmdlines.]

Caveat: some modes may fail for frozen executables unless an alternate Python
executable's path is available and passed here (see the note on this below).

New Mar-2017: StartAny subsumes prior Start and StartArgs which are deprecated;
it's better to keep the many platform-specific twists in just one place. 

----
ABOUT COMMAND-LINE QUOTING (and/or escaping)
New Mar-2017: use shlex.split() to handle command lines and args with unusual
shell syntax ('\' escapes, quotes, nested spaces, etc.), for which space splits
don't suffice.  shlex.quote() requires Python 3.3 or later: fallback if absent.

UPDATE: it turns out that Python's shlex module does not work for command lines
on Windows at all.  Confusingly, its "posix=False" option simply applies to
POSIX conformance on Unix system.  Windows shlex support has been requested
of Python developers since at least 2008, to no avail.  As is, shlex.split()
is Unix-only AND shlex.quote() is Py 3.3+-only (yes, blah).

As this developer has neither time nor enthusiasm for implementing such a tool
from scratch for the PyEdit project (and loathes specializing code for this
glaring feature hole at all), a workaround was developed: in all modes on
Windows, Python-executable and user-file pathnames are now double-quoted if
required, and command-line arguments are passed to the system exactly as
provided by the user.  This delegates argument correctness to users, and is
possible using Popen's string-based shell=True mode to avoid cmdline splits.

Caveat: this workaround supports spaces and special characters in Windows
filenames, but still not embedded quotes.  It's not clear (and TBD) if nested
quotes are even possible or legal on Windows.  Given that there are two or more
different syntax systems at work in some Windows command lines, though, this
seems a reasonable compromise.  Name Windows files per winpathnamesafe below.
###################################################################################
"""

import sys, os, shlex, subprocess

# code and run these just once
RunningOnWindows = sys.platform.startswith('win')      # or [:3] == 'win'
RunningOnMacOSX  = sys.platform.startswith('darwin')
RunningOnLinux   = sys.platform.startswith('linux')
RunningOnPython3 = sys.version[0] == '3'

# specialize for Unix 2.X/3.X (some use cases)
PYFILE = ('python.exe' if RunningOnWindows else        # or "py -3", "py -2"
         ('python3'    if RunningOnPython3 else
          'python'))

# use sys to locate python executable in newer pys
"""
----------------------------------------------------------------------
CAVEAT: PYPATH (and modes that use it) work for source code and Mac
app bundles, but does not work for single-file frozen executables,
because the caller is the executable, not python, and there is no 
separate python executable unless one was installed.  To work around,
freeze the target too, or route cmd back to a new instance of the exe
to be run in-process (e.g., via special cmdline arg and import/exec).

UPDATE: to address this, a 'python' constructor arg here overrides
sys.executable with another Python's path.  This allows users to
give an alternate Python path when either needed by modes or desired.
Example: PyEdit allows Py path to be set in its textConfig.py file.
----------------------------------------------------------------------
"""
PYPATH = sys.executable    # full path to python running this (for source, app)


#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Utilities
#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++


# characters that can always be used without quotes on Windows
# these are now special-cased to avoid quote semantics if possible
# embedded spaces and others not here require qoutes on windows
import string
winpathnamesafe = string.ascii_letters + string.digits + r'-_\.:'

# and maybe '()@|'? (punt! - life is too short to muck stalls like this)
winspecial = " &<>[]{}^=;!'+,`~\t"   # not used


def quoteCmdlineItem(item, winsafe=winpathnamesafe):
    """
    ------------------------------------------------------------------
    quote a single command-line item as possible: shlex.quote() does
    not work on Windows and is available in py 3.3+ only; Python has
    no cmdline parser or quoter for Windows, so quote here naively to
    allow nested spaces and specials, but not nested quotes (if they
    are legal at all);  it's ok to always add "" on Windows, but
    avoid if not required to minimize convolution;

    On Windows, this is coded to handle pathnames (including that of
    a file being run as well as python executables) by default: pass
    in alternative winsafe strings if needed for other use cases;
    On Unix (Mac, Linux), winsafe is not used;

    UPDATE: even MSoft's own docs seem to disagree on the characters
    that are special in command lines, so this now always quotes if
    not all safe chars;  yes, this is naive and perhaps even wrong 
    but this story spans multiple systems with differing and ad-hoc
    rules, and is too large a tragedy to pursue further here...
    ------------------------------------------------------------------
    """
    if sys.platform.startswith('win'):
        # Windows: allow embedded spaces, specials
        # caveat - does not handle nested " quotes (if possible)
        if any(c not in winsafe for c in item):
            return '"' + item + '"'                   # '"C:\Program Files\"'
        else:
            return item                               # 'C:\python35\py.exe'
        
    elif hasattr(shlex, 'quote'):
        # Unix, py 3.3+:  shell syntax
        # really just enclosing '' + nesteds, if needed
        return shlex.quote(item) 

    else:
        # Unix, py 3.2-: non-expanding shell quotes
        # caveat - does not handle nested ' quotes 
        return "'" + item + "'"


#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Launch-mode classes
#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++


class LaunchMode:
    """
    ------------------------------------------------------------------
    on call to instance, announce label and run cmdline;
    subclasses format command lines as required in run(); 
    cmdline should begin with name of the Python script
    file to run, and not with "python" or its full path;
    mar-2017: strip() cmdline (needed by some unix modes);
    mar-2017: extend to allow a configurable self.python;
    mar-2017: add fixpaths to allow preformated cmdlines;
    ------------------------------------------------------------------
    """
    def __init__(self, label, cmdline='', python=None):
        self.what  = label
        self.where = cmdline.strip()        # always drop any l+r whitespace 
        if python == None:
            self.python = PYPATH            # sys.executable or path passed in
        else:
            self.python = python            # caller-configurable pyton exe
 
    def __call__(self):                     # on "()" call (ex: button callback)
        self.announce(self.what)
        self.run(self.where)                # subclasses must define run()

    def announce(self, text):               # subclasses may redefine announce()
        print(text)                         # methods instead of if/elif logic

    def run(self, cmdline):
        assert False, 'run must be defined'
        

class System(LaunchMode): 
    """
    ----------------------------------------------------------
    run Python script named in shell command line cmdline;
    cmdline is assumed to be properly escaped/quoted;
    caveat: may block caller, unless a & added on Unix;
    caveat: may fail for frozen exes with no Py (see above);
    ----------------------------------------------------------
    """
    def run(self, cmdline):
        quotepython = quoteCmdlineItem(self.python)
        if RunningOnWindows:
            # '%s %s' fails if python is quoted
            quoteproofcmd = 'cmd.exe /S /C "%s %s"'
            os.system(quoteproofcmd % (quotepython, cmdline))
        else:
            # add a & so doesn't block on Unix
            if not cmdline.rstrip().endswith('&'):
                cmdline += ' &' 
            os.system('%s %s' % (quotepython, cmdline))


class Fork(LaunchMode):
    """
    ----------------------------------------------------------
    run cmdline in an explicitly-created new process;
    for Unix-like systems only, including Mac, Linux, cygwin;
    caveat: may fail for frozen exes with no Py (see above);
    mar-2017: shlex.split() per shell syntax, not on spaces;
    this doesn't care that shlex fails on Windows: fork=unix;
    ----------------------------------------------------------
    """
    def run(self, cmdline):
        if not hasattr(os, 'fork'):
            assert False, 'Platform unsupported by Fork call'

        # run new program in new child process
        cmdline = shlex.split(cmdline)   # assume posix
        if os.fork() == 0:
            os.execvp(self.python, [self.python] + cmdline)   # not PYFILE


class StartAny(LaunchMode):
    """
    ----------------------------------------------------------
    open as if clicked in system's file explorer;
    run cmdline independent of caller, no python;
    initially for Windows: filename associations;
    now works on Mac OS X and Linux the same way;

    this is a "super-start" - it handles filename quoting
    if needed, args passing as required per platform, and
    keep-console mode (for Windows), but assumes callers
    know about cmdargs and can pass them separately;
    
    caller: pass in an unquoted filename, plus cmdargs as
    a single string already formatted per the host platform's
    shell, or None if no cmdargs are used;  this sidesteps
    the need to split or format cmdarg lists, and thus
    allows their correctness to be delegated to endusers;
    
    this works even if cmd is not a Python script, and does
    not fail for frozen executables, but relies completely
    on association settings on the system;

    caveat: on Windows, this might make extra console windows
    for .pyw files if either args or keep are used (as tested);
    subprocess.Popen didn't help on this, and it might be
    related to a specific machine's filename associations;
    PyEdit Console/Capure modes avoid this: use for .pyw;
    
    dec-2016: extend for Mac OS X "open" command;
    mar-2017: extend for Linux "xdg-open" command;
    mar-2017: rewrite--subsumes prior Start+StartArgs;
    ----------------------------------------------------------
    """
    def __init__(self,
                 label,          # displayed to stdout on call ()
                 file,           # pathname of file to open, unquoted
                 args=None,      # string of prequoted/escaped cmdargs
                 keep=False      # retain console, where can (Windows)
                 ):              # NO python executable passed/used here
        self.file = file
        self.args = args.strip()
        self.keep = keep
        LaunchMode.__init__(self, label)   # superclass still routes ()

    def run(self, *unused):
        if not self.args:
            # special-case no-argument starts - like a click
            if RunningOnWindows:
                # like a DOS 'start'
                # caveat: may make extra consoles for .pyw with args or keep
                if not self.keep:
                    os.startfile(self.file)   # don't quote name
                else:
                    file = quoteCmdlineItem(self.file)
                    os.system('start cmd /K ' + file)   # stay-open trickery
                
            elif RunningOnMacOSX:
                # Mac OS X equivalent
                os.system('open ' + quoteCmdlineItem(self.file))

            elif RunningOnLinux:
                # Linux (most) equivalent
                os.system('xdg-open ' + quoteCmdlineItem(self.file))

            else:
                assert False, 'Platform unsupported by Start call'

        else:
            # start with cmdline args, where and how supported
            if RunningOnWindows:
                # requires a real 'start' + quoting, keep mode available
                # caveat: may make extra consoles for .pyw with args or keep
                cmdline = quoteCmdlineItem(self.file) + ' ' + self.args
                if not self.keep:
                    os.system('start cmd /S /C "%s"' % cmdline)  # allow for quotes
                else:
                    os.system('start cmd /S /K "%s"' % cmdline)  # stay-open trickery

            elif RunningOnMacOSX:
                # must pass args specially, else taken as files to open
                # caveat: --args go to _app_ (e.g., pylauncher), not script
                file = quoteCmdlineItem(self.file)
                os.system('open %s --args %s' % (file, self.args))

            elif RunningOnLinux:
                # quote filename for shell, runs per linux support
                cmdline = quoteCmdlineItem(self.file) + ' ' + self.args
                os.system('xdg-open ' + cmdline)
            else:
                assert False, 'Platform unsupported by Start call'


class Spawn(LaunchMode):
    """
    ----------------------------------------------------------
    run python in new process independent of caller;
    for Windows or Unix; use P_NOWAIT for dos box (?);
    forward slashes are okay here (unlike Start);
    
    mar-2017: allow mode to be passed in, pick default
    per platform default (os has no P_DETACH on Unix);
    mar-2017: strip and split cmdline for UNIX if args;
    
    mar-2017: shlex.split() per shell syntax, not on spaces,
    on unix platforms that support this call (see above);
    caveat: may fail for frozen exes with no Py (see above);

    mar-2017: rewritten to use subprocess, per note below,
    making many former notes moot (this code is evolving);
    ----------------------------------------------------------
    """
    def run(self, cmdline):
        #
        # mar-2017: converted to use subprocess.Popen() instead
        # of os.spawnv(), because the former allows cmdline to
        # remain a string, and hence sidesteps the nasty issue
        # of splitting Windows command line syntax; os.spawnv()
        # takes a single arg/item as full cmdline on Windows, but
        # this is little-known, undocumented, and unreliable quirk;
        # downside: unline sequences, strs require manual quoting;
        #
        python = quoteCmdlineItem(self.python)
        subprocess.Popen(python + ' ' + cmdline, shell=True) 


#
# pick a "best" launcher for this platform
# may need to specialize the choice elsewhere 
#

if RunningOnWindows:
    PortableLauncher = Spawn
else:                           
    PortableLauncher = Fork 

class QuietPortableLauncher(PortableLauncher):
    def announce(self, text): 
        pass


#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Deprecated (and otherwise dead) code, to be cut soon
#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++


# Prior Spawn version: delete me soon (prior "mode" arg already nuked)
        """--------------------------------------------------------
        if self.mode != None:
            mode = self.mode        # passed-in mode, else default
        elif RunningOnWindows:
            mode = os.P_DETACH      # this implies P_NOWAIT
        else:
            mode = os.P_NOWAIT      # Unix has no P_DETACH

        # separate script and any args
        if RunningOnWindows:
            # windows: entire cmline str as one arg
            os.spawnv(mode, self.python, PYFILE + ' ' + cmdline) 
            cmdline = cmdline.split()         # fall back on whitespace only
        else:
            # unix: do the posix split thing
            cmdline = shlex.split(cmdline)
            os.spawnv(mode, self.python, [PYFILE] + cmdline) 
        --------------------------------------------------------"""


def fixWindowsPath(cmdline):
    """
    ------------------------------------------------------------------
    DEPRECACTED - this is too naive to be useful anymore.
    change all / to \ in script filename path at front of cmdline;
    used only by classes which run tools that require this on Windows;
    on other platforms, this does not hurt (e.g., os.system on Unix),
    and may even help to simplify some pathological script pathnames;

    caveat: should probably also quote items with spaces on Windows;
    mar-2017: shlex.split() per shell syntax, not naively on spaces,
    and shlex.quote() to requote properly (if supported: see above);

    caveat: as of mar-2017, this is NOW UNUSED CODE retained as a
    legacy example; because this is naive on Windows, callers are
    better off handling script name normalizing and quoting manually; 
    ------------------------------------------------------------------
    """
    assert False, 'No longer available'
    """
    if RunningOnWindows:
        # handle simple but normal cases on windows
        splitline = cmdline.split()                   # assume whitespace
        fixedpath = os.path.normpath(splitline[0])    # fix fwd slashes
        return ' '.join([fixedpath] + splitline[1:])  # put back together
    else:
        # do it the right way on unix
        splitline = shlex.split(cmdline)              # assume posix mode
        fixedpath = os.path.normpath(splitline[0])    # fix path oddities
        return ' '.join(quoteCmdlineItem(x)
                        for x in [fixedpath] + splitline[1:])
    """


def macOpenCommand(cmdline):
    """
    ----------------------------------------------------------
    DEPRECATED - new StartAny class refactors this logic.
    
    helper for Mac "open" command, used in two launchers;
    cmd args require --args, and parsing per shell syntax;
    this doesn't care that shlex fails on Windows: Mac call;
    but it does need to avoid its quote() in Pythons <= 3.2;
    tbd: open -n flag opens a new instance of app - option?

    UNFORTUNATELY, any arguments in cmdline are not passed
    to Python scripts by the Mac "open": items after --args
    go instead to the PythonLauncher app (and items without
    --args are seen as files to be opened).  This mode is
    still useful on Mac for no-arg Python scripts, and may
    also be used to send args to other apps for other files;

    This should probably go away too if StartAny ever full
    subsumes Start and StartArgs; it lingers temporarily...
    ----------------------------------------------------------
    """
    cmdsplit = shlex.split(cmdline)
    if len(cmdsplit) == 1:
        # simple filename open
        os.system('open ' + cmdline)
    else:
        # pass args specially, requote
        file = quoteCmdlineItem(cmdsplit[0])
        args = ' '.join(quoteCmdlineItem(x) for x in cmdsplit[1:])
        os.system('open %s --args %s' % (file, args))
        

class Start(LaunchMode):
    """
    ----------------------------------------------------------
    DEPRECATED - use StartAny above if possible
    
    open as if clicked in system's file explorer;
    run cmdline independent of caller, no python;
    initially for Windows: filename associations;
    cmdline assumed to be properly escaped/quoted;

    this works even if cmd is not a Python script,
    and does not fail for frozen executables, but
    relies on association settings on the system;
    the caller is not blocked by the program run;

    dec-2016: extend for Mac OS X "open" command;
    mar-2017: extend for Linux "xdg-open" command;
    ----------------------------------------------------------
    """
    def __init__(self, label, cmdline):
        LaunchMode.__init__(self, label, cmdline)     # no python arg here

    def run(self, cmdline):
        if RunningOnWindows:
            os.startfile(cmdline)                     # like a DOS 'start'

        elif RunningOnMacOSX:
            macOpenCommand(cmdline)                   # Mac OS X equivalent

        elif RunningOnLinux:
            os.system('xdg-open ' + cmdline)          # Linux equivalent

        else:
            assert False, 'Platform unsupported by Start call'


class StartArgs(LaunchMode):
    """
    ----------------------------------------------------------
    DEPRECATED - use StartAny above if possible

    open as if clicked in system's file explorer;
    on Windows only, args may require real "start";
    forward slashes (/) are okay here on Windows;
    cmdline assumed to be properly escaped/quoted;
    
    this works even if cmd is not a Python script,
    and does not fail for frozen executables, but
    relies on association settings on the system;
    the caller is not blocked by the program run;
    
    dec-2016: extend for Mac OS X "open" command;
    mar-2017: extend for Linux "xdg-open" command;
        
    mar-2017: add arg to 'keep' new Windows Command
    Prompt open, so user need not add closing input();
    "open -a Terminal script.py" is similar on Mac,
    but cmdline args fail; "gnome-terminal" may be
    similar on some Linux, but may not work on all;
    ----------------------------------------------------------
    """
    def __init__(self, label, cmdline, keep=False):
        self.keep = keep
        LaunchMode.__init__(self, label, cmdline)     # no python arg here

    def run(self, cmdline):
        if RunningOnWindows:                          # run DOS start cmd
            if not self.keep:
                os.system('start ' + cmdline)         # may create window
            else:
                os.system('start cmd /K ' + cmdline)  # stay-open trickery

        elif RunningOnMacOSX:
            # ignore self.keep
            macOpenCommand(cmdline)                   # Mac OS X equivalent

        elif RunningOnLinux:
            # ignore self.keep
            os.system('xdg-open ' + cmdline)          # Linux equivalent

        else:
            assert False, 'Platform unsupported by StartArgs call'


class Popen(LaunchMode):
    """
    ----------------------------------------------------------
    DEPRECATED - this is prone to errors on Unix;
    run shell command line cmdline in a new process;
    cmdline is assumed to be properly escaped/quoted;
    caveat: may block caller, since pipe closed too soon;
    caveat: may fail for frozen exex with no Py (see above);
    caveat: fails badly on Unix - should probably be cut;
    ----------------------------------------------------------
    """
    def run(self, cmdline):
        quotepython = quoteCmdlineItem(self.python)
        os.popen(quotepython + ' ' + cmdline)   # assume nothing to be read


class Top_level(LaunchMode):
    """
    DEPRECATED - this was never used
    run in new window, same process
    tbd: requires GUI class info too
    """
    def run(self, cmdline):
        assert False, 'Sorry - Top_level mode not yet implemented'


#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Self-text (when __main__)
#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++


def selftest():
    """
    run me in a console/shell/terminal for best results (not IDLE);
    none of these block the caller, except System on Windows;
    the Start* modes fail to pass cmdargs to the spawned script on Mac;
    """

    if RunningOnWindows:
        args = '"spam spam spam" "-meaning of life"'
    else:
        args = '"spam spam spam" -meaning\ of\ life'

    def runit(file, doargs):
        mode = lambda modearg: ((' ' + modearg) if doargs else '')
        cmd  = quoteCmdlineItem(file) + ((' ' + args) if doargs else '')
        print('file: %r, cmd: %r' % (file, cmd))

        # blocks on Windows ('&' on Unix)
        print('System mode...')
        System(cmd, cmd + mode('-system'))()                                 

        # now uses subprocess.Popen, not os.spawnv
        print('Spawn mode...')
        Spawn(cmd, cmd + mode('-spawn'))()

        # per assoc, args not passed on Mac
        print('StartAny mode...')
        StartAny(cmd, file, (args if doargs else '') + mode('-startany'))()

        # spawn on Windows, fork on Unix
        print('default mode...')
        launcher = PortableLauncher(cmd, cmd + mode('-portable'))
        launcher()                                             

        
    from tkinter import Tk, Button, W
    root = Tk()
    for file in ['echogui.py',  'echo gui.py', 'echogui.pyw', 'echo gui.pyw']:
        for doargs in [True, False]:
            test = Button(root,
                text=file + (' + args' if doargs else ' (noargs)'),
                command=lambda file=file, doargs=doargs: runit(file, doargs))
            test.pack(anchor=W)
    root.mainloop()

    
if __name__ == '__main__': selftest()



[Home page] Books Code Blog Python Author Train Find ©M.Lutz