Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
path: root/IPython/irunner.py
blob: 7539802185fd85954d7f770f970a0748919483c1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
#!/usr/bin/env python
"""Module for interactively running scripts.

This module implements classes for interactively running scripts written for
any system with a prompt which can be matched by a regexp suitable for
pexpect.  It can be used to run as if they had been typed up interactively, an
arbitrary series of commands for the target system.

The module includes classes ready for IPython (with the default prompts),
plain Python and SAGE, but making a new one is trivial.  To see how to use it,
simply run the module as a script:

./irunner.py --help


This is an extension of Ken Schutte <kschutte-AT-csail.mit.edu>'s script
contributed on the ipython-user list:

http://scipy.net/pipermail/ipython-user/2006-May/001705.html


NOTES:

 - This module requires pexpect, available in most linux distros, or which can
 be downloaded from

    http://pexpect.sourceforge.net

 - Because pexpect only works under Unix or Windows-Cygwin, this has the same
 limitations.  This means that it will NOT work under native windows Python.
"""

# Stdlib imports
import optparse
import os
import sys

# Third-party modules.
import pexpect

# Global usage strings, to avoid indentation issues when typing it below.
USAGE = """
Interactive script runner, type: %s

runner [opts] script_name
"""

def pexpect_monkeypatch():
    """Patch pexpect to prevent unhandled exceptions at VM teardown.

    Calling this function will monkeypatch the pexpect.spawn class and modify
    its __del__ method to make it more robust in the face of failures that can
    occur if it is called when the Python VM is shutting down.

    Since Python may fire __del__ methods arbitrarily late, it's possible for
    them to execute during the teardown of the Python VM itself.  At this
    point, various builtin modules have been reset to None.  Thus, the call to
    self.close() will trigger an exception because it tries to call os.close(),
    and os is now None.
    """
    
    if pexpect.__version__[:3] >= '2.2':
        # No need to patch, fix is already the upstream version.
        return
    
    def __del__(self):
        """This makes sure that no system resources are left open.
        Python only garbage collects Python objects. OS file descriptors
        are not Python objects, so they must be handled explicitly.
        If the child file descriptor was opened outside of this class
        (passed to the constructor) then this does not close it.
        """
        if not self.closed:
            try:
                self.close()
            except AttributeError:
                pass

    pexpect.spawn.__del__ = __del__

pexpect_monkeypatch()

# The generic runner class
class InteractiveRunner(object):
    """Class to run a sequence of commands through an interactive program."""
    
    def __init__(self,program,prompts,args=None,out=sys.stdout,echo=True):
        """Construct a runner.

        Inputs:

          - program: command to execute the given program.

          - prompts: a list of patterns to match as valid prompts, in the
          format used by pexpect.  This basically means that it can be either
          a string (to be compiled as a regular expression) or a list of such
          (it must be a true list, as pexpect does type checks).

          If more than one prompt is given, the first is treated as the main
          program prompt and the others as 'continuation' prompts, like
          python's.  This means that blank lines in the input source are
          ommitted when the first prompt is matched, but are NOT ommitted when
          the continuation one matches, since this is how python signals the
          end of multiline input interactively.

        Optional inputs:

          - args(None): optional list of strings to pass as arguments to the
          child program.

          - out(sys.stdout): if given, an output stream to be used when writing
          output.  The only requirement is that it must have a .write() method.

        Public members not parameterized in the constructor:

          - delaybeforesend(0): Newer versions of pexpect have a delay before
          sending each new input.  For our purposes here, it's typically best
          to just set this to zero, but if you encounter reliability problems
          or want an interactive run to pause briefly at each prompt, just
          increase this value (it is measured in seconds).  Note that this
          variable is not honored at all by older versions of pexpect.
        """
        
        self.program = program
        self.prompts = prompts
        if args is None: args = []
        self.args = args
        self.out = out
        self.echo = echo
        # Other public members which we don't make as parameters, but which
        # users may occasionally want to tweak
        self.delaybeforesend = 0

        # Create child process and hold on to it so we don't have to re-create
        # for every single execution call
        c = self.child = pexpect.spawn(self.program,self.args,timeout=None)
        c.delaybeforesend = self.delaybeforesend
        # pexpect hard-codes the terminal size as (24,80) (rows,columns).
        # This causes problems because any line longer than 80 characters gets
        # completely overwrapped on the printed outptut (even though
        # internally the code runs fine).  We reset this to 99 rows X 200
        # columns (arbitrarily chosen), which should avoid problems in all
        # reasonable cases.
        c.setwinsize(99,200)

    def close(self):
        """close child process"""

        self.child.close()

    def run_file(self,fname,interact=False,get_output=False):
        """Run the given file interactively.

        Inputs:

          -fname: name of the file to execute.

        See the run_source docstring for the meaning of the optional
        arguments."""

        fobj = open(fname,'r')
        try:
            out = self.run_source(fobj,interact,get_output)
        finally:
            fobj.close()
        if get_output:
            return out

    def run_source(self,source,interact=False,get_output=False):
        """Run the given source code interactively.

        Inputs:

          - source: a string of code to be executed, or an open file object we
          can iterate over.

        Optional inputs:

          - interact(False): if true, start to interact with the running
          program at the end of the script.  Otherwise, just exit.

          - get_output(False): if true, capture the output of the child process
          (filtering the input commands out) and return it as a string.

        Returns:
          A string containing the process output, but only if requested.
          """

        # if the source is a string, chop it up in lines so we can iterate
        # over it just as if it were an open file.
        if not isinstance(source,file):
            source = source.splitlines(True)

        if self.echo:
            # normalize all strings we write to use the native OS line
            # separators.
            linesep  = os.linesep
            stdwrite = self.out.write
            write    = lambda s: stdwrite(s.replace('\r\n',linesep))
        else:
            # Quiet mode, all writes are no-ops
            write = lambda s: None
            
        c = self.child
        prompts = c.compile_pattern_list(self.prompts)
        prompt_idx = c.expect_list(prompts)

        # Flag whether the script ends normally or not, to know whether we can
        # do anything further with the underlying process.
        end_normal = True

        # If the output was requested, store it in a list for return at the end
        if get_output:
            output = []
            store_output = output.append
        
        for cmd in source:
            # skip blank lines for all matches to the 'main' prompt, while the
            # secondary prompts do not
            if prompt_idx==0 and \
                   (cmd.isspace() or cmd.lstrip().startswith('#')):
                write(cmd)
                continue

            #write('AFTER: '+c.after)  # dbg
            write(c.after)
            c.send(cmd)
            try:
                prompt_idx = c.expect_list(prompts)
            except pexpect.EOF:
                # this will happen if the child dies unexpectedly
                write(c.before)
                end_normal = False
                break
            
            write(c.before)

            # With an echoing process, the output we get in c.before contains
            # the command sent, a newline, and then the actual process output
            if get_output:
                store_output(c.before[len(cmd+'\n'):])
                #write('CMD: <<%s>>' % cmd)  # dbg
                #write('OUTPUT: <<%s>>' % output[-1])  # dbg

        self.out.flush()
        if end_normal:
            if interact:
                c.send('\n')
                print '<< Starting interactive mode >>',
                try:
                    c.interact()
                except OSError:
                    # This is what fires when the child stops.  Simply print a
                    # newline so the system prompt is aligned.  The extra
                    # space is there to make sure it gets printed, otherwise
                    # OS buffering sometimes just suppresses it.
                    write(' \n')
                    self.out.flush()
        else:
            if interact:
                e="Further interaction is not possible: child process is dead."
                print >> sys.stderr, e

        # Leave the child ready for more input later on, otherwise select just
        # hangs on the second invocation.
        c.send('\n')
        
        # Return any requested output
        if get_output:
            return ''.join(output)
                
    def main(self,argv=None):
        """Run as a command-line script."""

        parser = optparse.OptionParser(usage=USAGE % self.__class__.__name__)
        newopt = parser.add_option
        newopt('-i','--interact',action='store_true',default=False,
               help='Interact with the program after the script is run.')

        opts,args = parser.parse_args(argv)

        if len(args) != 1:
            print >> sys.stderr,"You must supply exactly one file to run."
            sys.exit(1)

        self.run_file(args[0],opts.interact)


# Specific runners for particular programs
class IPythonRunner(InteractiveRunner):
    """Interactive IPython runner.

    This initalizes IPython in 'nocolor' mode for simplicity.  This lets us
    avoid having to write a regexp that matches ANSI sequences, though pexpect
    does support them.  If anyone contributes patches for ANSI color support,
    they will be welcome.

    It also sets the prompts manually, since the prompt regexps for
    pexpect need to be matched to the actual prompts, so user-customized
    prompts would break this.
    """
    
    def __init__(self,program = 'ipython',args=None,out=sys.stdout,echo=True):
        """New runner, optionally passing the ipython command to use."""
        
        args0 = ['-colors','NoColor',
                 '-pi1','In [\\#]: ',
                 '-pi2','   .\\D.: ',
                 '-noterm_title',
                 '-noautoindent']
        if args is None: args = args0
        else: args = args0 + args
        prompts = [r'In \[\d+\]: ',r'   \.*: ']
        InteractiveRunner.__init__(self,program,prompts,args,out,echo)


class PythonRunner(InteractiveRunner):
    """Interactive Python runner."""

    def __init__(self,program='python',args=None,out=sys.stdout,echo=True):
        """New runner, optionally passing the python command to use."""

        prompts = [r'>>> ',r'\.\.\. ']
        InteractiveRunner.__init__(self,program,prompts,args,out,echo)


class SAGERunner(InteractiveRunner):
    """Interactive SAGE runner.
    
    WARNING: this runner only works if you manually configure your SAGE copy
    to use 'colors NoColor' in the ipythonrc config file, since currently the
    prompt matching regexp does not identify color sequences."""

    def __init__(self,program='sage',args=None,out=sys.stdout,echo=True):
        """New runner, optionally passing the sage command to use."""

        prompts = ['sage: ',r'\s*\.\.\. ']
        InteractiveRunner.__init__(self,program,prompts,args,out,echo)


class RunnerFactory(object):
    """Code runner factory.

    This class provides an IPython code runner, but enforces that only one
    runner is ever instantiated.  The runner is created based on the extension
    of the first file to run, and it raises an exception if a runner is later
    requested for a different extension type.

    This ensures that we don't generate example files for doctest with a mix of
    python and ipython syntax.
    """

    def __init__(self,out=sys.stdout):
        """Instantiate a code runner."""
        
        self.out = out
        self.runner = None
        self.runnerClass = None

    def _makeRunner(self,runnerClass):
        self.runnerClass = runnerClass
        self.runner = runnerClass(out=self.out)
        return self.runner
          
    def __call__(self,fname):
        """Return a runner for the given filename."""

        if fname.endswith('.py'):
            runnerClass = PythonRunner
        elif fname.endswith('.ipy'):
            runnerClass = IPythonRunner
        else:
            raise ValueError('Unknown file type for Runner: %r' % fname)

        if self.runner is None:
            return self._makeRunner(runnerClass)
        else:
            if runnerClass==self.runnerClass:
                return self.runner
            else:
                e='A runner of type %r can not run file %r' % \
                   (self.runnerClass,fname)
                raise ValueError(e)


# Global usage string, to avoid indentation issues if typed in a function def.
MAIN_USAGE = """
%prog [options] file_to_run

This is an interface to the various interactive runners available in this
module.  If you want to pass specific options to one of the runners, you need
to first terminate the main options with a '--', and then provide the runner's
options.  For example:

irunner.py --python -- --help

will pass --help to the python runner.  Similarly,

irunner.py --ipython -- --interact script.ipy

will run the script.ipy file under the IPython runner, and then will start to
interact with IPython at the end of the script (instead of exiting).

The already implemented runners are listed below; adding one for a new program
is a trivial task, see the source for examples.

WARNING: the SAGE runner only works if you manually configure your SAGE copy
to use 'colors NoColor' in the ipythonrc config file, since currently the
prompt matching regexp does not identify color sequences.
"""

def main():
    """Run as a command-line script."""

    parser = optparse.OptionParser(usage=MAIN_USAGE)
    newopt = parser.add_option
    parser.set_defaults(mode='ipython')
    newopt('--ipython',action='store_const',dest='mode',const='ipython',
           help='IPython interactive runner (default).')
    newopt('--python',action='store_const',dest='mode',const='python',
           help='Python interactive runner.')
    newopt('--sage',action='store_const',dest='mode',const='sage',
           help='SAGE interactive runner.')

    opts,args = parser.parse_args()
    runners = dict(ipython=IPythonRunner,
                   python=PythonRunner,
                   sage=SAGERunner)

    try:
        ext = os.path.splitext(args[0])[-1]
    except IndexError:
        ext = ''
    modes = {'.ipy':'ipython',
             '.py':'python',
             '.sage':'sage'}
    mode = modes.get(ext,opts.mode)
    runners[mode]().main(args)

if __name__ == '__main__':
    main()