Should I dual ranger/cleric or wait for the THAC0 bonus?
[ranger.git] / ranger / ext / rifle.py
blob3f360968ae87dad046374653d31b5e6e1f6d2409
1 #!/usr/bin/python
2 # This file is part of ranger, the console file manager.
3 # License: GNU GPL version 3, see the file "AUTHORS" for details.
5 """rifle, the file executor/opener of ranger
7 This can be used as a standalone program or can be embedded in python code.
8 When used together with ranger, it doesn't have to be installed to $PATH.
10 Example usage:
12 rifle = Rifle("rifle.conf")
13 rifle.reload_config()
14 rifle.execute(["file1", "file2"])
15 """
17 import os.path
18 import re
19 from subprocess import Popen, PIPE
20 import sys
22 __version__ = 'rifle 1.7.0'
24 # Options and constants that a user might want to change:
25 DEFAULT_PAGER = 'less'
26 DEFAULT_EDITOR = 'vim'
27 ASK_COMMAND = 'ask'
28 ENCODING = 'utf-8'
30 # Imports from ranger library, plus reimplementations in case ranger is not
31 # installed so rifle can be run as a standalone program.
32 try:
33 from ranger.ext.get_executables import get_executables
34 except ImportError:
35 _cached_executables = None
37 def get_executables():
38 """Return all executable files in $PATH + Cache them."""
39 global _cached_executables
40 if _cached_executables is not None:
41 return _cached_executables
43 if 'PATH' in os.environ:
44 paths = os.environ['PATH'].split(':')
45 else:
46 paths = ['/usr/bin', '/bin']
48 from stat import S_IXOTH, S_IFREG
49 paths_seen = set()
50 _cached_executables = set()
51 for path in paths:
52 if path in paths_seen:
53 continue
54 paths_seen.add(path)
55 try:
56 content = os.listdir(path)
57 except OSError:
58 continue
59 for item in content:
60 abspath = path + '/' + item
61 try:
62 filestat = os.stat(abspath)
63 except OSError:
64 continue
65 if filestat.st_mode & (S_IXOTH | S_IFREG):
66 _cached_executables.add(item)
67 return _cached_executables
70 try:
71 from ranger.ext.popen_forked import Popen_forked
72 except ImportError:
73 def Popen_forked(*args, **kwargs):
74 """Forks process and runs Popen with the given args and kwargs."""
75 try:
76 pid = os.fork()
77 except OSError:
78 return False
79 if pid == 0:
80 os.setsid()
81 kwargs['stdin'] = open(os.devnull, 'r')
82 kwargs['stdout'] = kwargs['stderr'] = open(os.devnull, 'w')
83 Popen(*args, **kwargs)
84 os._exit(0)
85 return True
88 def _is_terminal():
89 # Check if stdin (file descriptor 0), stdout (fd 1) and
90 # stderr (fd 2) are connected to a terminal
91 try:
92 os.ttyname(0)
93 os.ttyname(1)
94 os.ttyname(2)
95 except:
96 return False
97 return True
100 def squash_flags(flags):
101 """Remove lowercase flags if the respective uppercase flag exists
103 >>> squash_flags('abc')
104 'abc'
105 >>> squash_flags('abcC')
106 'ab'
107 >>> squash_flags('CabcAd')
108 'bd'
110 exclude = ''.join(f.upper() + f.lower() for f in flags if f == f.upper())
111 return ''.join(f for f in flags if f not in exclude)
114 class Rifle(object):
115 delimiter1 = '='
116 delimiter2 = ','
118 # TODO: Test all of the hooks properly
119 def hook_before_executing(self, command, mimetype, flags):
120 pass
122 def hook_after_executing(self, command, mimetype, flags):
123 pass
125 def hook_command_preprocessing(self, command):
126 return command
128 def hook_command_postprocessing(self, command):
129 return command
131 def hook_environment(self, env):
132 return env
134 def hook_logger(self, string):
135 sys.stderr.write(string + "\n")
137 def __init__(self, config_file):
138 self.config_file = config_file
139 self._app_flags = ''
140 self._app_label = None
141 self._initialized_mimetypes = False
143 # get paths for mimetype files
144 self._mimetype_known_files = [
145 os.path.expanduser("~/.mime.types")]
146 if __file__.endswith("ranger/ext/rifle.py"):
147 # Add ranger's default mimetypes when run from ranger directory
148 self._mimetype_known_files.append(
149 __file__.replace("ext/rifle.py", "data/mime.types"))
151 def reload_config(self, config_file=None):
152 """Replace the current configuration with the one in config_file"""
153 if config_file is None:
154 config_file = self.config_file
155 f = open(config_file, 'r')
156 self.rules = []
157 lineno = 1
158 for line in f:
159 if line.startswith('#') or line == '\n':
160 continue
161 line = line.strip()
162 try:
163 if self.delimiter1 not in line:
164 raise Exception("Line without delimiter")
165 tests, command = line.split(self.delimiter1, 1)
166 tests = tests.split(self.delimiter2)
167 tests = tuple(tuple(f.strip().split(None, 1)) for f in tests)
168 command = command.strip()
169 self.rules.append((command, tests))
170 except Exception as e:
171 self.hook_logger("Syntax error in %s line %d (%s)" % \
172 (config_file, lineno, str(e)))
173 lineno += 1
174 f.close()
176 def _eval_condition(self, condition, files, label):
177 # Handle the negation of conditions starting with an exclamation mark,
178 # then pass on the arguments to _eval_condition2().
180 if not condition:
181 return True
182 if condition[0].startswith('!'):
183 new_condition = tuple([condition[0][1:]]) + tuple(condition[1:])
184 return not self._eval_condition2(new_condition, files, label)
185 return self._eval_condition2(condition, files, label)
187 def _eval_condition2(self, rule, files, label):
188 # This function evaluates the condition, after _eval_condition() handled
189 # negation of conditions starting with a "!".
191 if not files:
192 return False
194 function = rule[0]
195 argument = rule[1] if len(rule) > 1 else ''
197 if function == 'ext':
198 extension = os.path.basename(files[0]).rsplit('.', 1)[-1].lower()
199 return bool(re.search('^(' + argument + ')$', extension))
200 elif function == 'name':
201 return bool(re.search(argument, os.path.basename(files[0])))
202 elif function == 'match':
203 return bool(re.search(argument, files[0]))
204 elif function == 'file':
205 return os.path.isfile(files[0])
206 elif function == 'directory':
207 return os.path.isdir(files[0])
208 elif function == 'path':
209 return bool(re.search(argument, os.path.abspath(files[0])))
210 elif function == 'mime':
211 return bool(re.search(argument, self._get_mimetype(files[0])))
212 elif function == 'has':
213 if argument.startswith("$"):
214 if argument[1:] in os.environ:
215 return os.environ[argument[1:]] in get_executables()
216 return False
217 else:
218 return argument in get_executables()
219 elif function == 'terminal':
220 return _is_terminal()
221 elif function == 'number':
222 if argument.isdigit():
223 self._skip = int(argument)
224 return True
225 elif function == 'label':
226 self._app_label = argument
227 if label:
228 return argument == label
229 return True
230 elif function == 'flag':
231 self._app_flags = argument
232 return True
233 elif function == 'X':
234 return 'DISPLAY' in os.environ
235 elif function == 'else':
236 return True
238 def _get_mimetype(self, fname):
239 # Spawn "file" to determine the mime-type of the given file.
240 if self._mimetype:
241 return self._mimetype
243 import mimetypes
244 for path in self._mimetype_known_files:
245 if path not in mimetypes.knownfiles:
246 mimetypes.knownfiles.append(path)
247 self._mimetype, encoding = mimetypes.guess_type(fname)
249 if not self._mimetype:
250 process = Popen(["file", "--mime-type", "-Lb", fname],
251 stdout=PIPE, stderr=PIPE)
252 mimetype, _ = process.communicate()
253 self._mimetype = mimetype.decode(ENCODING).strip()
254 return self._mimetype
256 def _build_command(self, files, action, flags):
257 # Get the flags
258 if isinstance(flags, str):
259 self._app_flags += flags
260 self._app_flags = squash_flags(self._app_flags)
261 filenames = "' '".join(f.replace("'", "'\\\''") for f in files
262 if "\x00" not in f)
263 return "set -- '%s'; %s" % (filenames, action)
265 def list_commands(self, files, mimetype=None):
266 """List all commands that are applicable for the given files
268 Returns one 4-tuple for all currently applicable commands
269 The 4-tuple contains (count, command, label, flags).
270 count is the index, counted from 0 upwards,
271 command is the command that will be executed.
272 label and flags are the label and flags specified in the rule.
274 self._mimetype = mimetype
275 count = -1
276 for cmd, tests in self.rules:
277 self._skip = None
278 self._app_flags = ''
279 self._app_label = None
280 for test in tests:
281 if not self._eval_condition(test, files, None):
282 break
283 else:
284 if self._skip is None:
285 count += 1
286 else:
287 count = self._skip
288 yield (count, cmd, self._app_label, self._app_flags)
290 def execute(self, files, number=0, label=None, flags="", mimetype=None):
291 """Executes the given list of files.
293 By default, this executes the first command where all conditions apply,
294 but by specifying number=N you can run the 1+Nth command.
296 If a label is specified, only rules with this label will be considered.
298 If you specify the mimetype, rifle will not try to determine it itself.
300 By specifying a flag, you extend the flag that is defined in the rule.
301 Uppercase flags negate the respective lowercase flags.
302 For example: if the flag in the rule is "pw" and you specify "Pf", then
303 the "p" flag is negated and the "f" flag is added, resulting in "wf".
305 command = None
306 found_at_least_one = None
308 # Determine command
309 for count, cmd, lbl, flgs in self.list_commands(files, mimetype):
310 if label and label == lbl or not label and count == number:
311 cmd = self.hook_command_preprocessing(cmd)
312 if cmd == ASK_COMMAND:
313 return ASK_COMMAND
314 command = self._build_command(files, cmd, flags + flgs)
315 flags = self._app_flags
316 break
317 else:
318 found_at_least_one = True
319 else:
320 if label and label in get_executables():
321 cmd = '%s "$@"' % label
322 command = self._build_command(files, cmd, flags)
324 # Execute command
325 if command is None:
326 if found_at_least_one:
327 if label:
328 self.hook_logger("Label '%s' is undefined" % label)
329 else:
330 self.hook_logger("Method number %d is undefined." % number)
331 else:
332 self.hook_logger("No action found.")
333 else:
334 if 'PAGER' not in os.environ:
335 os.environ['PAGER'] = DEFAULT_PAGER
336 if 'EDITOR' not in os.environ:
337 os.environ['EDITOR'] = DEFAULT_EDITOR
338 command = self.hook_command_postprocessing(command)
339 self.hook_before_executing(command, self._mimetype, self._app_flags)
340 try:
341 if 'r' in flags:
342 prefix = ['sudo', '-E', 'su', '-mc']
343 else:
344 prefix = ['/bin/sh', '-c']
346 cmd = prefix + [command]
347 if 't' in flags:
348 if 'TERMCMD' not in os.environ:
349 term = os.environ['TERM']
350 if term.startswith('rxvt-unicode'):
351 term = 'urxvt'
352 if term not in get_executables():
353 self.hook_logger("Can not determine terminal command. "
354 "Please set $TERMCMD manually.")
355 # A fallback terminal that is likely installed:
356 term = 'xterm'
357 os.environ['TERMCMD'] = term
358 cmd = [os.environ['TERMCMD'], '-e'] + cmd
359 if 'f' in flags or 't' in flags:
360 Popen_forked(cmd, env=self.hook_environment(os.environ))
361 else:
362 p = Popen(cmd, env=self.hook_environment(os.environ))
363 p.wait()
364 finally:
365 self.hook_after_executing(command, self._mimetype, self._app_flags)
368 def main():
369 """The main function which is run when you start this program direectly."""
370 import sys
372 # Find configuration file path
373 if 'XDG_CONFIG_HOME' in os.environ and os.environ['XDG_CONFIG_HOME']:
374 conf_path = os.environ['XDG_CONFIG_HOME'] + '/ranger/rifle.conf'
375 else:
376 conf_path = os.path.expanduser('~/.config/ranger/rifle.conf')
377 default_conf_path = conf_path
378 if not os.path.isfile(conf_path):
379 conf_path = os.path.normpath(os.path.join(os.path.dirname(__file__),
380 '../config/rifle.conf'))
381 if not os.path.isfile(conf_path):
382 try:
383 # if ranger is installed, get the configuration from ranger
384 import ranger
385 except ImportError:
386 pass
387 else:
388 conf_path = os.path.join(ranger.__path__[0], "config", "rifle.conf")
391 # Evaluate arguments
392 from optparse import OptionParser
393 parser = OptionParser(usage="%prog [-fhlpw] [files]", version=__version__)
394 parser.add_option('-f', type="string", default="", metavar="FLAGS",
395 help="use additional flags: f=fork, r=root, t=terminal. "
396 "Uppercase flag negates respective lowercase flags.")
397 parser.add_option('-l', action="store_true",
398 help="list possible ways to open the files (id:label:flags:command)")
399 parser.add_option('-p', type='string', default='0', metavar="KEYWORD",
400 help="pick a method to open the files. KEYWORD is either the "
401 "number listed by 'rifle -l' or a string that matches a label in "
402 "the configuration file")
403 parser.add_option('-w', type='string', default=None, metavar="PROGRAM",
404 help="open the files with PROGRAM")
405 options, positional = parser.parse_args()
406 if not positional:
407 parser.print_help()
408 raise SystemExit(1)
410 if not os.path.isfile(conf_path):
411 sys.stderr.write("Could not find a configuration file.\n"
412 "Please create one at %s.\n" % default_conf_path)
413 raise SystemExit(1)
415 if options.p.isdigit():
416 number = int(options.p)
417 label = None
418 else:
419 number = 0
420 label = options.p
422 if options.w is not None and not options.l:
423 p = Popen([options.w] + list(positional))
424 p.wait()
425 else:
426 # Start up rifle
427 rifle = Rifle(conf_path)
428 rifle.reload_config()
429 #print(rifle.list_commands(sys.argv[1:]))
430 if options.l:
431 for count, cmd, label, flags in rifle.list_commands(positional):
432 print("%d:%s:%s:%s" % (count, label or '', flags, cmd))
433 else:
434 result = rifle.execute(positional, number=number, label=label,
435 flags=options.f)
436 if result == ASK_COMMAND:
437 # TODO: implement interactive asking for file type?
438 print("Unknown file type: %s" % rifle._get_mimetype(positional[0]))
442 if __name__ == '__main__':
443 if 'RANGER_DOCTEST' in os.environ:
444 import doctest
445 doctest.testmod()
446 else:
447 main()