115c70328a45ba6da1b284a3fdc9aeb9312f517d
[hooke.git] / hooke / command.py
1 # Copyright
2
3 """The `command` module provides :class:`Command`\s and
4 :class:`Argument`\s for defining commands.
5
6 It also provides :class:`CommandExit` and subclasses for communicating
7 command completion information between
8 :class:`hooke.engine.CommandEngine`\s and
9 :class:`hooke.ui.UserInterface`\s.
10 """
11
12 import Queue as queue
13 import sys
14 import textwrap
15 import traceback
16
17
18 class CommandExit (Exception):
19     pass
20
21 class Success (CommandExit):
22     pass
23
24 class Exit (Success):
25     """The command requests an end to the interpreter session.
26     """
27     pass
28
29 class Failure (CommandExit):
30     pass
31
32 class UncaughtException (Failure):
33     def __init__(self, exception, traceback_string=None):
34         super(UncaughtException, self).__init__()
35         if traceback_string == None:
36             traceback_string = traceback.format_exc()
37             sys.exc_clear()
38         self.exception = exception
39         self.traceback = traceback_string
40         self.__setstate__(self.__getstate__())
41
42     def __getstate__(self):
43         """Return a picklable representation of the objects state.
44
45         :mod:`pickle`'s doesn't call a :meth:`__init__` when
46         rebuilding a class instance.  To preserve :attr:`args` through
47         a pickle cycle, we use :meth:`__getstate__` and
48         :meth:`__setstate__`.
49
50         See `pickling class instances`_ and `pickling examples`_.
51
52         .. _pickling class instances:
53           http://docs.python.org/library/pickle.html#pickling-and-unpickling-normal-class-instances
54         .. _pickling examples:
55           http://docs.python.org/library/pickle.html#example
56         """
57         return {'exception':self.exception, 'traceback':self.traceback}
58
59     def __setstate__(self, state):
60         """Apply the picklable state from :meth:`__getstate__` to
61         reconstruct the instance.
62         """
63         for key,value in state.items():
64             setattr(self, key, value)
65         self.args = (self.traceback + str(self.exception),)
66
67
68 class Command (object):
69     """One-line command description here.
70
71     >>> c = Command(name='test', help='An example Command.')
72     >>> hooke = None
73     >>> status = c.run(hooke, NullQueue(), PrintQueue(),
74     ...                help=True) # doctest: +REPORT_UDIFF
75     ITEM:
76     Command: test
77     <BLANKLINE>
78     Arguments:
79     <BLANKLINE>
80     help BOOL (bool) Print a help message.
81     <BLANKLINE>
82     An example Command.
83     ITEM:
84     <BLANKLINE>
85     """
86     def __init__(self, name, aliases=None, arguments=[], help=''):
87         self.name = name
88         if aliases == None:
89             aliases = []
90         self.aliases = aliases
91         self.arguments = [
92             Argument(name='help', type='bool', default=False, count=1,
93                      help='Print a help message.'),
94             ] + arguments
95         self._help = help
96
97     def run(self, hooke, inqueue=None, outqueue=None, **kwargs):
98         """`Normalize inputs and handle <Argument help> before punting
99         to :meth:`_run`.
100         """
101         if inqueue == None:
102             inqueue = NullQueue()
103         if outqueue == None:
104             outqueue = NullQueue()
105         try:
106             params = self.handle_arguments(hooke, inqueue, outqueue, kwargs)
107             if params['help'] == True:
108                 outqueue.put(self.help())
109                 raise(Success())
110             self._run(hooke, inqueue, outqueue, params)
111         except CommandExit, e:
112             if isinstance(e, Failure):
113                 outqueue.put(e)
114                 return 1
115             # other CommandExit subclasses fall through to the end
116         except Exception, e:
117             x = UncaughtException(e)
118             outqueue.put(x)
119             return 1
120         else:
121             e = Success()
122         outqueue.put(e)
123         return 0
124
125     def _run(self, inqueue, outqueue, params):
126         """This is where the command-specific magic will happen.
127         """
128         pass
129
130     def handle_arguments(self, hooke, inqueue, outqueue, params):
131         """Normalize and validate input parameters (:class:`Argument` values).
132         """
133         for argument in self.arguments:
134             names = [argument.name] + argument.aliases
135             settings = [(name,v) for name,v in params.items() if name in names]
136             num_provided = len(settings)
137             if num_provided == 0:
138                 if argument.optional == True or argument.count == 0:
139                     settings = [(argument.name, argument.default)]
140                 else:
141                     raise Failure('Required argument %s not set.'
142                                   % argument.name)
143             if num_provided > 1:
144                 raise Failure('Multiple settings for %s:\n  %s'
145                     % (argument.name,
146                        '\n  '.join(['%s: %s' % (name,value)
147                                     for name,value in sorted(settings)])))
148             name,value = settings[0]
149             if num_provided == 0:
150                 params[argument.name] = value
151             else:
152                 if name != argument.name:
153                     params.remove(name)
154                     params[argument.name] = value
155             if argument.callback != None:
156                 value = argument.callback(hooke, self, argument, value)
157                 params[argument.name] = value
158             argument.validate(value)
159         return params
160
161     def help(self, name_fn=lambda name:name):
162         """Return a help message describing the `Command`.
163
164         `name_fn(internal_name) -> external_name` gives calling
165         :class:`hooke.ui.UserInterface`\s a means of changing the
166         display names if it wants (e.g. to remove spaces from command
167         line tokens).
168         """
169         name_part = 'Command: %s' % name_fn(self.name)
170         if len(self.aliases) > 0:
171             name_part += ' (%s)' % ', '.join(
172                 [name_fn(n) for n in self.aliases])
173         parts = [name_part]
174         if len(self.arguments) > 0:
175             argument_part = ['Arguments:', '']
176             for a in self.arguments:
177                 argument_part.append(textwrap.fill(
178                         a.help(name_fn),
179                         initial_indent="",
180                         subsequent_indent="    "))
181             argument_part = '\n'.join(argument_part)
182             parts.append(argument_part)
183         parts.append(self._help) # help part
184         return '\n\n'.join(parts)
185
186 class Argument (object):
187     """Structured user input for :class:`Command`\s.
188     
189     TODO: ranges for `count`?
190     """
191     def __init__(self, name, aliases=None, type='string', metavar=None,
192                  default=None, optional=True, count=1,
193                  completion_callback=None, callback=None, help=''):
194         self.name = name
195         if aliases == None:
196             aliases = []
197         self.aliases = aliases
198         self.type = type
199         if metavar == None:
200             metavar = type.upper()
201         self.metavar = metavar
202         self.default = default
203         self.optional = optional
204         self.count = count
205         self.completion_callback = completion_callback
206         self.callback = callback
207         self._help = help
208
209     def __str__(self):
210         return '<%s %s>' % (self.__class__.__name__, self.name)
211
212     def __repr__(self):
213         return self.__str__()
214
215     def help(self, name_fn=lambda name:name):
216         """Return a help message describing the `Argument`.
217
218         `name_fn(internal_name) -> external_name` gives calling
219         :class:`hooke.ui.UserInterface`\s a means of changing the
220         display names if it wants (e.g. to remove spaces from command
221         line tokens).
222         """        
223         parts = ['%s ' % name_fn(self.name)]
224         if self.metavar != None:
225             parts.append('%s ' % self.metavar)
226         parts.extend(['(%s) ' % self.type, self._help])
227         return ''.join(parts)
228
229     def validate(self, value):
230         """If `value` is not appropriate, raise `ValueError`.
231         """
232         pass # TODO: validation
233
234     # TODO: type conversion
235
236 # TODO: type extensions?
237
238 # Useful callbacks
239
240 class StoreValue (object):
241     def __init__(self, value):
242         self.value = value
243     def __call__(self, hooke, command, argument, fragment=None):
244         return self.value
245
246 class NullQueue (queue.Queue):
247     """The :class:`queue.Queue` equivalent of `/dev/null`.
248
249     This is a bottomless pit.  Items go in, but never come out.
250     """
251     def get(self, block=True, timeout=None):
252         """Raise queue.Empty.
253         
254         There's really no need to override the base Queue.get, but I
255         want to know if someone tries to read from a NullQueue.  With
256         the default implementation they would just block silently
257         forever :(.
258         """
259         raise queue.Empty
260
261     def put(self, item, block=True, timeout=None):
262         """Dump an item into the void.
263
264         Block and timeout are meaningless, because there is always a
265         free slot available in a bottomless pit.
266         """
267         pass
268
269 class PrintQueue (NullQueue):
270     """Debugging :class:`NullQueue` that prints items before dropping
271     them.
272     """
273     def put(self, item, block=True, timeout=None):
274         """Print `item` and then dump it into the void.
275         """
276         print 'ITEM:\n%s' % item