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