├── Makefile.am ├── configure.ac ├── README.md ├── .gitignore ├── tellme-default.conf ├── tellme.man └── tellme.py /Makefile.am: -------------------------------------------------------------------------------- 1 | dist_bin_SCRIPTS = tellme 2 | tellmedir = $(datadir)/tellme/ 3 | tellme_DATA = tellme-default.conf 4 | 5 | SUFFIXES = .1 6 | 7 | SED_SUBS=-e "s|\@datarootdir\@|$(datarootdir)|" 8 | 9 | if HAVE_PANDOC 10 | dist_man1_MANS = tellme.1 11 | .man.1: 12 | sed $(SED_SUBS) $^ | $(PANDOC) -s -t man -o $@ 13 | endif 14 | 15 | tellme: tellme.py 16 | sed $(SED_SUBS) $^ > $@ 17 | 18 | CLEANFILES = $(dist_man1_MANS) $(dist_bin_SCRIPTS) 19 | EXTRA_DIST = tellme.py tellme-default.conf tellme.man 20 | -------------------------------------------------------------------------------- /configure.ac: -------------------------------------------------------------------------------- 1 | AC_PREREQ([2.60]) 2 | AC_INIT([text-to-speech command execution notifier], 3 | [0.1], 4 | [], 5 | [tellme]) 6 | 7 | AM_INIT_AUTOMAKE([foreign dist-xz no-dist-gzip]) 8 | AM_SILENT_RULES([yes]) 9 | 10 | # We don't actually need a compiler, but automake complains about missing AMDEP 11 | AC_PROG_CC 12 | AC_PROG_INSTALL 13 | AM_PATH_PYTHON([2.6]) 14 | AC_ARG_VAR([PANDOC], [Path to pandoc command]) 15 | AC_PATH_PROG([PANDOC], [pandoc]) 16 | if test "x$PANDOC" = "x"; then 17 | AC_MSG_WARN([pandoc not found - required for man pages]) 18 | fi 19 | AM_CONDITIONAL(HAVE_PANDOC, [test "x$PANDOC" != "x"]) 20 | 21 | AC_PATH_PROG([FESTIVAL], [festival]) 22 | if test "x$FESTIVAL" = "x"; then 23 | AC_MSG_WARN([festival not found on this machine - required for voice output]) 24 | fi 25 | 26 | AC_CONFIG_FILES([Makefile]) 27 | AC_OUTPUT 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | tellme - text-to-speech command execution notifier 2 | ======================================= 3 | 4 | tellme runs the command and notify the user through text-to-speech when the 5 | process finishes. 6 | 7 | For example: 8 | 9 | tellme sudo yum update 10 | 11 | will eventually say "finished yum update successfully". Yes, it's smart 12 | enough to strip the sudo out. Some configuration options are available, see 13 | the example configuration file. With that file, 14 | 15 | cd myproject 16 | tellme make install 17 | 18 | will eventually say "finished myproject make install successfully". 19 | 20 | Installation 21 | ------------ 22 | 23 | autoreconf -ivf 24 | ./configure --prefix=/usr 25 | make 26 | sudo make install 27 | 28 | Dependencies 29 | ------------ 30 | 31 | festival is currently hardcoded as the text-to-speech engine. 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 2 | # X.Org module default exclusion patterns 3 | # The next section if for module specific patterns 4 | # 5 | # Do not edit the following section 6 | # GNU Build System (Autotools) 7 | aclocal.m4 8 | autom4te.cache/ 9 | autoscan.log 10 | ChangeLog 11 | compile 12 | config.guess 13 | config.h 14 | config.h.in 15 | config.log 16 | config-ml.in 17 | config.py 18 | config.status 19 | config.status.lineno 20 | config.sub 21 | configure 22 | configure.scan 23 | depcomp 24 | .deps/ 25 | INSTALL 26 | install-sh 27 | .libs/ 28 | libtool 29 | libtool.m4 30 | ltmain.sh 31 | lt~obsolete.m4 32 | ltoptions.m4 33 | ltsugar.m4 34 | ltversion.m4 35 | Makefile 36 | Makefile.in 37 | mdate-sh 38 | missing 39 | mkinstalldirs 40 | *.pc 41 | py-compile 42 | stamp-h? 43 | symlink-tree 44 | test-driver 45 | texinfo.tex 46 | ylwrap 47 | 48 | # Do not edit the following section 49 | # Edit Compile Debug Document Distribute 50 | *~ 51 | *.[0-9] 52 | *.[0-9]x 53 | *.bak 54 | *.bin 55 | core 56 | *.dll 57 | *.exe 58 | *-ISO*.bdf 59 | *-JIS*.bdf 60 | *-KOI8*.bdf 61 | *.kld 62 | *.ko 63 | *.ko.cmd 64 | *.lai 65 | *.l[oa] 66 | *.[oa] 67 | *.obj 68 | *.patch 69 | *.so 70 | *.pcf.gz 71 | *.pdb 72 | *.tar.bz2 73 | *.tar.gz 74 | 75 | # Local patterns 76 | .vimdir 77 | *.swp 78 | -------------------------------------------------------------------------------- /tellme-default.conf: -------------------------------------------------------------------------------- 1 | # General configuration applies to all commands, but can be overridden with 2 | # command-specific sections. The config parser supports multiple files, so 3 | # either append to the main configuration, or drop a "somecommand.conf" in 4 | # the configuration directory. 5 | # 6 | # Configuration directory is XDG_CONFIG_HOME/tellme/, extra configuration 7 | # files can be applied to any directory and apply from that directory down. 8 | # Exact config merging behavior is directly dependent on the ConfigParser 9 | # python module. 10 | [general] 11 | # When to talk? 12 | # always: on success or error (default) 13 | # error: only for non-zero exit status 14 | # talk=always 15 | 16 | # say the directory basename? 17 | # 18 | # none: do nothing (default when missing) 19 | # cwd: say the basename of cwd 20 | # git: run up the hierarchy to find a .git directory, then say that, 21 | # otherwise the basename of pwd 22 | directory=cwd 23 | 24 | # space separated list of regex to replace directory names. First character 25 | # in each expression is the field separator, expressions are whatever python 26 | # is happy with. 27 | # regexes here are not cumulative, processing stops after the first one 28 | # applied 29 | # processing chain 30 | dirsubs=/xf86-input-// :xf86-video-:: 31 | 32 | # This section affects the behavior for the "make" command 33 | [make] 34 | directory=git 35 | # Define which arguments are spoken and which are filtered. Both lists are 36 | # space-separated lists of regexes. If a whitelist exists, only arguments in 37 | # the whitelist are spoken. If both exists and an argument matches both, the 38 | # blacklist takes precedence. 39 | blacklist=-.* 40 | whitelist=(install|clean|all|check) 41 | 42 | [yum] 43 | whitelist=(update|install|remove) 44 | directory=none 45 | 46 | [rdiff] 47 | directory=none 48 | 49 | [git] 50 | directory=git 51 | blacklist=(-.*|HEAD) 52 | -------------------------------------------------------------------------------- /tellme.man: -------------------------------------------------------------------------------- 1 | % TELLME(1) 2 | % Peter Hutterer 3 | 4 | # NAME 5 | 6 | tellme - text-to-speech command execution notifier 7 | 8 | # SYNOPSIS 9 | 10 | tellme [sudo] command [arg] [arg] .. 11 | 12 | # DESCRIPTION 13 | 14 | **tellme** runs the command and notify the user through text-to-speech when the 15 | process finishes. In a default invocation, **tellme** notifies the user 16 | whether the process finished successfully or with an error code. Depending 17 | on the configuration options (see CONFIGURATION) **tellme** may also add 18 | directory names, remove, add or substitute arguments. 19 | 20 | tellme recognises the **sudo(8)** command and strips it from the voice output, 21 | it is thus not necessary to run **tellme** itself as root. 22 | 23 | # CONFIGURATION FILES 24 | 25 | Configuration is supported through one or more configuration files. The 26 | primary sets of configuration files are the system defaults in 27 | **@datarootdir@/tellme/\*.conf** and user-defaults in 28 | **XDG_CONFIG_HOME/tellme/\*.conf**. These files are always loaded. 29 | 30 | Per-directory overrides may be placed in **$PWD/.tellme/\*.conf**. 31 | **tellme** runs upwards from the current working directory to locate a 32 | **.tellme** configuration folder. On the first one found, the configuration 33 | files are added to the list. 34 | 35 | # CONFIGURATION DIRECTIVES 36 | 37 | Configuration directives are divided into a **[general]** section and 38 | command-specific sections. These sections are named after the command, i.e. 39 | for the **make** command the section is **[make]**. 40 | 41 | talk=always 42 | : Define when to talk. If unset or *always*, always talk when the process 43 | completes. If set to *error*, only talk when the process finishes with 44 | a non-zero exit code. 45 | 46 | directory=none 47 | : The directory included in the voice output. If unset or *none*, no 48 | directory is included. If *cwd*, the current directory's basename is 49 | added to the output. If *git*, **tellme** searches upwards for a 50 | directory containing a ".git" subdirectory. If found, that directory is 51 | added to the output, otherwise the current working directory is used. 52 | 53 | dirsubs=/search/replace/ /search/replace/ :search:replace: 54 | : A space-separated list of search/replace patterns that modify the 55 | directory name. The first regex that matches is applied and processing 56 | stops. The field separator is the first and last character of the 57 | string and separates the search from the replace pattern. 58 | 59 | blacklist=regex regex 60 | : A space-separated list of regular expressions. Any argument that matches 61 | any expression is dropped from the output. 62 | 63 | whitelist=regex regex 64 | : A space-separated list of regular expressions. If set, any argument that 65 | matches any expression is added to the output, unless prohibited by the 66 | blacklist. Any argument that does not match any whitelisted argument is 67 | dropped. 68 | 69 | # SEE ALSO 70 | 71 | festival(1) 72 | 73 | The XDG base directory specification 74 | 75 | 76 | -------------------------------------------------------------------------------- /tellme.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import print_function 4 | import errno 5 | import glob 6 | import re 7 | import sys 8 | import os 9 | import subprocess 10 | try: 11 | import configparser 12 | except ImportError: 13 | import ConfigParser as configparser 14 | 15 | def usage(): 16 | print("usage: %s command [options]" % os.path.basename(sys.argv[0])) 17 | 18 | def error(msg): 19 | print(msg, file=sys.stderr) 20 | sys.exit(1) 21 | 22 | class Command(object): 23 | sysconfigpath = "@datarootdir@" 24 | configpath = os.getenv("XDG_CONFIG_HOME", "%s/.config" % os.environ["HOME"]) 25 | def __init__(self, command, status): 26 | self.commandline = self._strip_sudo(command) 27 | self.binary = os.path.basename(self.commandline[0]) 28 | # default is just to speak the binary 29 | self.output = self.binary 30 | self.status = status 31 | 32 | self._apply_config() 33 | 34 | def _strip_sudo(self, args): 35 | if os.path.basename(args[0]) == "sudo": 36 | args = args[1:] 37 | return args 38 | 39 | def __str__(self): 40 | return self.output 41 | 42 | def _get_config_option(self, option): 43 | val = None 44 | if self.config.has_option("general", option): 45 | val = self.config.get("general", option) 46 | 47 | if self.config.has_option(self.binary, option): 48 | val = self.config.get(self.binary, option) 49 | return val 50 | 51 | def _apply_config(self): 52 | self.config = configparser.SafeConfigParser() 53 | 54 | paths = glob.glob("%s/tellme/*.conf" % (self.sysconfigpath)) 55 | paths += glob.glob("%s/tellme/*.conf" % (self.configpath)) 56 | 57 | # run up from cwd to first instance of .tellme existing 58 | cwd = os.getcwd() 59 | while not os.path.isdir("%s/.tellme" % cwd): 60 | cwd = os.path.realpath(cwd + "/..") 61 | if os.path.dirname(cwd) == cwd: 62 | break 63 | 64 | paths += glob.glob("%s/.tellme/*.conf" % (cwd)) 65 | 66 | if len(self.config.read(paths)) == 0: 67 | return 68 | 69 | command = self.binary 70 | if command == "general": 71 | error("Seriously? A command called general?") 72 | 73 | argstring = self._filter_args() 74 | directory = self._get_directory() 75 | self.output = "%s %s %s" % (directory, command, argstring) 76 | 77 | def _get_directory(self): 78 | want_dir = self._get_config_option("directory") 79 | 80 | path = "" 81 | if want_dir == "none": 82 | pass 83 | elif want_dir == "cwd": 84 | path = os.path.basename(os.getcwd()) 85 | elif want_dir == "git": 86 | path = os.getcwd() 87 | found = True 88 | while not os.path.isdir("%s/.git" % path): 89 | path = os.path.realpath(path + "/..") 90 | if os.path.dirname(path) == path: 91 | found = False 92 | break 93 | if found: 94 | path = os.path.basename(path) 95 | else: 96 | path = os.path.basename(os.getcwd()) 97 | return self._sub_directory(path) 98 | 99 | def _sub_directory(self, directory): 100 | subs = self._get_config_option("dirsubs") or "" 101 | subs = subs.split(" ") 102 | 103 | for pat in subs: 104 | if len(pat) == 0: 105 | continue 106 | if len(pat) <= 3: 107 | error("Invalid regex '%s' in dirsubs='%s'", pat, " ".join(subs)) 108 | fields = pat.split(pat[0]) 109 | if len(fields) < 4: 110 | error("Invalid regex '%s' in dirsubs='%s'", pat, " ".join(subs)) 111 | if re.match(fields[1], directory): 112 | directory = re.sub(fields[1], fields[2], directory) 113 | break 114 | return directory 115 | 116 | 117 | def _filter_args(self): 118 | wl = self._get_config_option("whitelist") or "" 119 | wl = [x for x in wl.split(" ") if len(x) > 0] 120 | bl = self._get_config_option("blacklist") or "" 121 | bl = [x for x in bl.split(" ") if len(x) > 0] 122 | 123 | args = self.commandline[1:] 124 | if len(bl) == 0 and len(wl) == 0: 125 | args = [] 126 | 127 | # drop all blacklisted args first 128 | for pat in bl: 129 | if pat == "": 130 | continue 131 | pattern = re.compile(pat) 132 | args = [ a for a in args if not pattern.match(a) ] 133 | 134 | # whitelist only applies if it exists 135 | if len(wl) > 0: 136 | whitelisted = [] 137 | for arg in args: 138 | matches = [ x for x in wl if re.match(x, arg) ] 139 | if len(matches) > 0: 140 | whitelisted.append(arg) 141 | args = whitelisted 142 | 143 | return " ".join(args) 144 | 145 | def talk(self): 146 | if self.status == 0: 147 | if self._get_config_option("talk") == "error": 148 | return 149 | 150 | # voice output can take a while, no need to wait until it finished. 151 | # call festival from the child process, so we don't hang the 152 | # terminal 153 | if os.fork() == 0: 154 | p = subprocess.Popen(["festival", "--tts"], stdin=subprocess.PIPE) 155 | msg = "finished %s" % str(self.output) 156 | if self.status != 0: 157 | msg += " with error %d" % self.status 158 | else: 159 | msg += " successfully" 160 | p.communicate(msg.encode("utf-8")) 161 | sys.exit(0) 162 | 163 | if __name__ == "__main__": 164 | if len(sys.argv) == 1: 165 | usage() 166 | sys.exit(1) 167 | 168 | try: 169 | command = sys.argv[1:] 170 | rc = subprocess.call(command, stdin=0, stdout=1, stderr=2) 171 | cmd = Command(command, rc) 172 | cmd.talk() 173 | sys.exit(rc) 174 | except OSError as e: 175 | if e.errno == errno.ENOENT: 176 | print("command not found: %s" % command[0]) 177 | else: 178 | print("%s: %s" % (command[0], e.strerror)) 179 | sys.exit(127) 180 | 181 | --------------------------------------------------------------------------------