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