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()