1 # copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved. |
|
2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr |
|
3 # |
|
4 # This file is part of CubicWeb. |
|
5 # |
|
6 # CubicWeb is free software: you can redistribute it and/or modify it under the |
|
7 # terms of the GNU Lesser General Public License as published by the Free |
|
8 # Software Foundation, either version 2.1 of the License, or (at your option) |
|
9 # any later version. |
|
10 # |
|
11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT |
|
12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
|
13 # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more |
|
14 # details. |
|
15 # |
|
16 # You should have received a copy of the GNU Lesser General Public License along |
|
17 # with CubicWeb. If not, see <http://www.gnu.org/licenses/>. |
|
18 """some utilities for cubicweb command line tools""" |
|
19 from __future__ import print_function |
|
20 |
|
21 __docformat__ = "restructuredtext en" |
|
22 |
|
23 # XXX move most of this in logilab.common (shellutils ?) |
|
24 |
|
25 import io |
|
26 import os, sys |
|
27 import subprocess |
|
28 from os import listdir, makedirs, environ, chmod, walk, remove |
|
29 from os.path import exists, join, abspath, normpath |
|
30 import re |
|
31 from rlcompleter import Completer |
|
32 try: |
|
33 import readline |
|
34 except ImportError: # readline not available, no completion |
|
35 pass |
|
36 try: |
|
37 from os import symlink |
|
38 except ImportError: |
|
39 def symlink(*args): |
|
40 raise NotImplementedError |
|
41 |
|
42 from six import add_metaclass |
|
43 |
|
44 from logilab.common.clcommands import Command as BaseCommand |
|
45 from logilab.common.shellutils import ASK |
|
46 |
|
47 from cubicweb import warning # pylint: disable=E0611 |
|
48 from cubicweb import ConfigurationError, ExecutionError |
|
49 |
|
50 def underline_title(title, car='-'): |
|
51 return title+'\n'+(car*len(title)) |
|
52 |
|
53 def iter_dir(directory, condition_file=None, ignore=()): |
|
54 """iterate on a directory""" |
|
55 for sub in listdir(directory): |
|
56 if sub in ('CVS', '.svn', '.hg'): |
|
57 continue |
|
58 if condition_file is not None and \ |
|
59 not exists(join(directory, sub, condition_file)): |
|
60 continue |
|
61 if sub in ignore: |
|
62 continue |
|
63 yield sub |
|
64 |
|
65 def create_dir(directory): |
|
66 """create a directory if it doesn't exist yet""" |
|
67 try: |
|
68 makedirs(directory) |
|
69 print('-> created directory %s' % directory) |
|
70 except OSError as ex: |
|
71 import errno |
|
72 if ex.errno != errno.EEXIST: |
|
73 raise |
|
74 print('-> no need to create existing directory %s' % directory) |
|
75 |
|
76 def create_symlink(source, target): |
|
77 """create a symbolic link""" |
|
78 if exists(target): |
|
79 remove(target) |
|
80 symlink(source, target) |
|
81 print('[symlink] %s <-- %s' % (target, source)) |
|
82 |
|
83 def create_copy(source, target): |
|
84 import shutil |
|
85 print('[copy] %s <-- %s' % (target, source)) |
|
86 shutil.copy2(source, target) |
|
87 |
|
88 def rm(whatever): |
|
89 import shutil |
|
90 shutil.rmtree(whatever) |
|
91 print('-> removed %s' % whatever) |
|
92 |
|
93 def show_diffs(appl_file, ref_file, askconfirm=True): |
|
94 """interactivly replace the old file with the new file according to |
|
95 user decision |
|
96 """ |
|
97 import shutil |
|
98 pipe = subprocess.Popen(['diff', '-u', appl_file, ref_file], stdout=subprocess.PIPE) |
|
99 diffs = pipe.stdout.read() |
|
100 if diffs: |
|
101 if askconfirm: |
|
102 print() |
|
103 print(diffs) |
|
104 action = ASK.ask('Replace ?', ('Y', 'n', 'q'), 'Y').lower() |
|
105 else: |
|
106 action = 'y' |
|
107 if action == 'y': |
|
108 try: |
|
109 shutil.copyfile(ref_file, appl_file) |
|
110 except IOError: |
|
111 os.system('chmod a+w %s' % appl_file) |
|
112 shutil.copyfile(ref_file, appl_file) |
|
113 print('replaced') |
|
114 elif action == 'q': |
|
115 sys.exit(0) |
|
116 else: |
|
117 copy_file = appl_file + '.default' |
|
118 copy = open(copy_file, 'w') |
|
119 copy.write(open(ref_file).read()) |
|
120 copy.close() |
|
121 print('keep current version, the new file has been written to', copy_file) |
|
122 else: |
|
123 print('no diff between %s and %s' % (appl_file, ref_file)) |
|
124 |
|
125 SKEL_EXCLUDE = ('*.py[co]', '*.orig', '*~', '*_flymake.py') |
|
126 def copy_skeleton(skeldir, targetdir, context, |
|
127 exclude=SKEL_EXCLUDE, askconfirm=False): |
|
128 import shutil |
|
129 from fnmatch import fnmatch |
|
130 skeldir = normpath(skeldir) |
|
131 targetdir = normpath(targetdir) |
|
132 for dirpath, dirnames, filenames in walk(skeldir): |
|
133 tdirpath = dirpath.replace(skeldir, targetdir) |
|
134 create_dir(tdirpath) |
|
135 for fname in filenames: |
|
136 if any(fnmatch(fname, pat) for pat in exclude): |
|
137 continue |
|
138 fpath = join(dirpath, fname) |
|
139 if 'CUBENAME' in fname: |
|
140 tfpath = join(tdirpath, fname.replace('CUBENAME', context['cubename'])) |
|
141 elif 'DISTNAME' in fname: |
|
142 tfpath = join(tdirpath, fname.replace('DISTNAME', context['distname'])) |
|
143 else: |
|
144 tfpath = join(tdirpath, fname) |
|
145 if fname.endswith('.tmpl'): |
|
146 tfpath = tfpath[:-5] |
|
147 if not askconfirm or not exists(tfpath) or \ |
|
148 ASK.confirm('%s exists, overwrite?' % tfpath): |
|
149 fill_templated_file(fpath, tfpath, context) |
|
150 print('[generate] %s <-- %s' % (tfpath, fpath)) |
|
151 elif exists(tfpath): |
|
152 show_diffs(tfpath, fpath, askconfirm) |
|
153 else: |
|
154 shutil.copyfile(fpath, tfpath) |
|
155 |
|
156 def fill_templated_file(fpath, tfpath, context): |
|
157 with io.open(fpath, encoding='ascii') as fobj: |
|
158 template = fobj.read() |
|
159 with io.open(tfpath, 'w', encoding='ascii') as fobj: |
|
160 fobj.write(template % context) |
|
161 |
|
162 def restrict_perms_to_user(filepath, log=None): |
|
163 """set -rw------- permission on the given file""" |
|
164 if log: |
|
165 log('set permissions to 0600 for %s', filepath) |
|
166 else: |
|
167 print('-> set permissions to 0600 for %s' % filepath) |
|
168 chmod(filepath, 0o600) |
|
169 |
|
170 def read_config(config_file, raise_if_unreadable=False): |
|
171 """read some simple configuration from `config_file` and return it as a |
|
172 dictionary. If `raise_if_unreadable` is false (the default), an empty |
|
173 dictionary will be returned if the file is inexistant or unreadable, else |
|
174 :exc:`ExecutionError` will be raised. |
|
175 """ |
|
176 from logilab.common.fileutils import lines |
|
177 config = current = {} |
|
178 try: |
|
179 for line in lines(config_file, comments='#'): |
|
180 try: |
|
181 option, value = line.split('=', 1) |
|
182 except ValueError: |
|
183 option = line.strip().lower() |
|
184 if option[0] == '[': |
|
185 # start a section |
|
186 section = option[1:-1] |
|
187 assert section not in config, \ |
|
188 'Section %s is defined more than once' % section |
|
189 config[section] = current = {} |
|
190 continue |
|
191 sys.stderr.write('ignoring malformed line\n%r\n' % line) |
|
192 continue |
|
193 option = option.strip().replace(' ', '_') |
|
194 value = value.strip() |
|
195 current[option] = value or None |
|
196 except IOError as ex: |
|
197 if raise_if_unreadable: |
|
198 raise ExecutionError('%s. Are you logged with the correct user ' |
|
199 'to use this instance?' % ex) |
|
200 else: |
|
201 warning('missing or non readable configuration file %s (%s)', |
|
202 config_file, ex) |
|
203 return config |
|
204 |
|
205 |
|
206 _HDLRS = {} |
|
207 |
|
208 class metacmdhandler(type): |
|
209 def __new__(mcs, name, bases, classdict): |
|
210 cls = super(metacmdhandler, mcs).__new__(mcs, name, bases, classdict) |
|
211 if getattr(cls, 'cfgname', None) and getattr(cls, 'cmdname', None): |
|
212 _HDLRS.setdefault(cls.cmdname, []).append(cls) |
|
213 return cls |
|
214 |
|
215 |
|
216 @add_metaclass(metacmdhandler) |
|
217 class CommandHandler(object): |
|
218 """configuration specific helper for cubicweb-ctl commands""" |
|
219 def __init__(self, config): |
|
220 self.config = config |
|
221 |
|
222 |
|
223 class Command(BaseCommand): |
|
224 """base class for cubicweb-ctl commands""" |
|
225 |
|
226 def config_helper(self, config, required=True, cmdname=None): |
|
227 if cmdname is None: |
|
228 cmdname = self.name |
|
229 for helpercls in _HDLRS.get(cmdname, ()): |
|
230 if helpercls.cfgname == config.name: |
|
231 return helpercls(config) |
|
232 if config.name == 'all-in-one': |
|
233 for helpercls in _HDLRS.get(cmdname, ()): |
|
234 if helpercls.cfgname == 'repository': |
|
235 return helpercls(config) |
|
236 if required: |
|
237 msg = 'No helper for command %s using %s configuration' % ( |
|
238 cmdname, config.name) |
|
239 raise ConfigurationError(msg) |
|
240 |
|
241 def fail(self, reason): |
|
242 print("command failed:", reason) |
|
243 sys.exit(1) |
|
244 |
|
245 |
|
246 CONNECT_OPTIONS = ( |
|
247 ("user", |
|
248 {'short': 'u', 'type' : 'string', 'metavar': '<user>', |
|
249 'help': 'connect as <user> instead of being prompted to give it.', |
|
250 } |
|
251 ), |
|
252 ("password", |
|
253 {'short': 'p', 'type' : 'password', 'metavar': '<password>', |
|
254 'help': 'automatically give <password> for authentication instead of \ |
|
255 being prompted to give it.', |
|
256 }), |
|
257 ("host", |
|
258 {'short': 'H', 'type' : 'string', 'metavar': '<hostname>', |
|
259 'default': None, |
|
260 'help': 'specify the name server\'s host name. Will be detected by \ |
|
261 broadcast if not provided.', |
|
262 }), |
|
263 ) |
|
264 |
|
265 ## cwshell helpers ############################################################# |
|
266 |
|
267 class AbstractMatcher(object): |
|
268 """Abstract class for CWShellCompleter's matchers. |
|
269 |
|
270 A matcher should implement a ``possible_matches`` method. This |
|
271 method has to return the list of possible completions for user's input. |
|
272 Because of the python / readline interaction, each completion should |
|
273 be a superset of the user's input. |
|
274 |
|
275 NOTE: readline tokenizes user's input and only passes last token to |
|
276 completers. |
|
277 """ |
|
278 |
|
279 def possible_matches(self, text): |
|
280 """return possible completions for user's input. |
|
281 |
|
282 Parameters: |
|
283 text: the user's input |
|
284 |
|
285 Return: |
|
286 a list of completions. Each completion includes the original input. |
|
287 """ |
|
288 raise NotImplementedError() |
|
289 |
|
290 |
|
291 class RQLExecuteMatcher(AbstractMatcher): |
|
292 """Custom matcher for rql queries. |
|
293 |
|
294 If user's input starts with ``rql(`` or ``session.execute(`` and |
|
295 the corresponding rql query is incomplete, suggest some valid completions. |
|
296 """ |
|
297 query_match_rgx = re.compile( |
|
298 r'(?P<func_prefix>\s*(?:rql)' # match rql, possibly indented |
|
299 r'|' # or |
|
300 r'\s*(?:\w+\.execute))' # match .execute, possibly indented |
|
301 # end of <func_prefix> |
|
302 r'\(' # followed by a parenthesis |
|
303 r'(?P<quote_delim>["\'])' # a quote or double quote |
|
304 r'(?P<parameters>.*)') # and some content |
|
305 |
|
306 def __init__(self, local_ctx, req): |
|
307 self.local_ctx = local_ctx |
|
308 self.req = req |
|
309 self.schema = req.vreg.schema |
|
310 self.rsb = req.vreg['components'].select('rql.suggestions', req) |
|
311 |
|
312 @staticmethod |
|
313 def match(text): |
|
314 """check if ``text`` looks like a call to ``rql`` or ``session.execute`` |
|
315 |
|
316 Parameters: |
|
317 text: the user's input |
|
318 |
|
319 Returns: |
|
320 None if it doesn't match, the query structure otherwise. |
|
321 """ |
|
322 query_match = RQLExecuteMatcher.query_match_rgx.match(text) |
|
323 if query_match is None: |
|
324 return None |
|
325 parameters_text = query_match.group('parameters') |
|
326 quote_delim = query_match.group('quote_delim') |
|
327 # first parameter is fully specified, no completion needed |
|
328 if re.match(r"(.*?)%s" % quote_delim, parameters_text) is not None: |
|
329 return None |
|
330 func_prefix = query_match.group('func_prefix') |
|
331 return { |
|
332 # user's input |
|
333 'text': text, |
|
334 # rql( or session.execute( |
|
335 'func_prefix': func_prefix, |
|
336 # offset of rql query |
|
337 'rql_offset': len(func_prefix) + 2, |
|
338 # incomplete rql query |
|
339 'rql_query': parameters_text, |
|
340 } |
|
341 |
|
342 def possible_matches(self, text): |
|
343 """call ``rql.suggestions`` component to complete user's input. |
|
344 """ |
|
345 # readline will only send last token, but we need the entire user's input |
|
346 user_input = readline.get_line_buffer() |
|
347 query_struct = self.match(user_input) |
|
348 if query_struct is None: |
|
349 return [] |
|
350 else: |
|
351 # we must only send completions of the last token => compute where it |
|
352 # starts relatively to the rql query itself. |
|
353 completion_offset = readline.get_begidx() - query_struct['rql_offset'] |
|
354 rql_query = query_struct['rql_query'] |
|
355 return [suggestion[completion_offset:] |
|
356 for suggestion in self.rsb.build_suggestions(rql_query)] |
|
357 |
|
358 |
|
359 class DefaultMatcher(AbstractMatcher): |
|
360 """Default matcher: delegate to standard's `rlcompleter.Completer`` class |
|
361 """ |
|
362 def __init__(self, local_ctx): |
|
363 self.completer = Completer(local_ctx) |
|
364 |
|
365 def possible_matches(self, text): |
|
366 if "." in text: |
|
367 return self.completer.attr_matches(text) |
|
368 else: |
|
369 return self.completer.global_matches(text) |
|
370 |
|
371 |
|
372 class CWShellCompleter(object): |
|
373 """Custom auto-completion helper for cubicweb-ctl shell. |
|
374 |
|
375 ``CWShellCompleter`` provides a ``complete`` method suitable for |
|
376 ``readline.set_completer``. |
|
377 |
|
378 Attributes: |
|
379 matchers: the list of ``AbstractMatcher`` instances that will suggest |
|
380 possible completions |
|
381 |
|
382 The completion process is the following: |
|
383 |
|
384 - readline calls the ``complete`` method with user's input, |
|
385 - the ``complete`` method asks for each known matchers if |
|
386 it can suggest completions for user's input. |
|
387 """ |
|
388 |
|
389 def __init__(self, local_ctx): |
|
390 # list of matchers to ask for possible matches on completion |
|
391 self.matchers = [DefaultMatcher(local_ctx)] |
|
392 self.matchers.insert(0, RQLExecuteMatcher(local_ctx, local_ctx['session'])) |
|
393 |
|
394 def complete(self, text, state): |
|
395 """readline's completer method |
|
396 |
|
397 cf http://docs.python.org/2/library/readline.html#readline.set_completer |
|
398 for more details. |
|
399 |
|
400 Implementation inspired by `rlcompleter.Completer` |
|
401 """ |
|
402 if state == 0: |
|
403 # reset self.matches |
|
404 self.matches = [] |
|
405 for matcher in self.matchers: |
|
406 matches = matcher.possible_matches(text) |
|
407 if matches: |
|
408 self.matches = matches |
|
409 break |
|
410 else: |
|
411 return None # no matcher able to handle `text` |
|
412 try: |
|
413 return self.matches[state] |
|
414 except IndexError: |
|
415 return None |
|