1import subprocess 2 3from loguru import logger 4 5from flexget import plugin 6from flexget.config_schema import one_or_more 7from flexget.entry import Entry 8from flexget.event import event 9from flexget.utils.template import RenderError, render_from_entry, render_from_task 10from flexget.utils.tools import io_encoding 11 12logger = logger.bind(name='exec') 13 14 15class EscapingEntry(Entry): 16 """Helper class, same as a Entry, but returns all string value with quotes escaped.""" 17 18 def __init__(self, entry): 19 super().__init__(entry) 20 21 def __getitem__(self, key): 22 value = super().__getitem__(key) 23 # TODO: May need to be different depending on OS 24 if isinstance(value, str): 25 value = value.replace('"', '\\"') 26 return value 27 28 29class PluginExec: 30 """ 31 Execute commands 32 33 Simple example, xecute command for entries that reach output:: 34 35 exec: echo 'found {{title}} at {{url}}' > file 36 37 Advanced Example:: 38 39 exec: 40 on_start: 41 phase: echo "Started" 42 on_input: 43 for_entries: echo 'got {{title}}' 44 on_output: 45 for_accepted: echo 'accepted {{title}} - {{url}} > file 46 47 You can use all (available) entry fields in the command. 48 """ 49 50 NAME = 'exec' 51 HANDLED_PHASES = ['start', 'input', 'filter', 'output', 'exit'] 52 53 schema = { 54 'oneOf': [ 55 one_or_more({'type': 'string'}), 56 { 57 'type': 'object', 58 'properties': { 59 'on_start': {'$ref': '#/definitions/phaseSettings'}, 60 'on_input': {'$ref': '#/definitions/phaseSettings'}, 61 'on_filter': {'$ref': '#/definitions/phaseSettings'}, 62 'on_output': {'$ref': '#/definitions/phaseSettings'}, 63 'on_exit': {'$ref': '#/definitions/phaseSettings'}, 64 'fail_entries': {'type': 'boolean'}, 65 'auto_escape': {'type': 'boolean'}, 66 'encoding': {'type': 'string'}, 67 'allow_background': {'type': 'boolean'}, 68 }, 69 'additionalProperties': False, 70 }, 71 ], 72 'definitions': { 73 'phaseSettings': { 74 'type': 'object', 75 'properties': { 76 'phase': one_or_more({'type': 'string'}), 77 'for_entries': one_or_more({'type': 'string'}), 78 'for_accepted': one_or_more({'type': 'string'}), 79 'for_rejected': one_or_more({'type': 'string'}), 80 'for_undecided': one_or_more({'type': 'string'}), 81 'for_failed': one_or_more({'type': 'string'}), 82 }, 83 'additionalProperties': False, 84 } 85 }, 86 } 87 88 def prepare_config(self, config): 89 if isinstance(config, str): 90 config = [config] 91 if isinstance(config, list): 92 config = {'on_output': {'for_accepted': config}} 93 if not config.get('encoding'): 94 config['encoding'] = io_encoding 95 for phase_name in config: 96 if phase_name.startswith('on_'): 97 for items_name in config[phase_name]: 98 if isinstance(config[phase_name][items_name], str): 99 config[phase_name][items_name] = [config[phase_name][items_name]] 100 101 return config 102 103 def execute_cmd(self, cmd, allow_background, encoding): 104 logger.verbose('Executing: {}', cmd) 105 p = subprocess.Popen( 106 cmd, 107 shell=True, 108 stdin=subprocess.PIPE, 109 stdout=subprocess.PIPE, 110 stderr=subprocess.STDOUT, 111 close_fds=False, 112 ) 113 if not allow_background: 114 r, w = (p.stdout, p.stdin) 115 response = r.read().decode(io_encoding) 116 r.close() 117 w.close() 118 if response: 119 logger.info('Stdout: {}', response.rstrip()) # rstrip to get rid of newlines 120 return p.wait() 121 122 def execute(self, task, phase_name, config): 123 config = self.prepare_config(config) 124 if phase_name not in config: 125 logger.debug('phase {} not configured', phase_name) 126 return 127 128 name_map = { 129 'for_entries': task.entries, 130 'for_accepted': task.accepted, 131 'for_rejected': task.rejected, 132 'for_undecided': task.undecided, 133 'for_failed': task.failed, 134 } 135 136 allow_background = config.get('allow_background') 137 for operation, entries in name_map.items(): 138 if operation not in config[phase_name]: 139 continue 140 141 logger.debug( 142 'running phase_name: {} operation: {} entries: {}', 143 phase_name, 144 operation, 145 len(entries), 146 ) 147 148 for entry in entries: 149 for cmd in config[phase_name][operation]: 150 entrydict = EscapingEntry(entry) if config.get('auto_escape') else entry 151 # Do string replacement from entry, but make sure quotes get escaped 152 try: 153 cmd = render_from_entry(cmd, entrydict) 154 except RenderError as e: 155 logger.error('Could not set exec command for {}: {}', entry['title'], e) 156 # fail the entry if configured to do so 157 if config.get('fail_entries'): 158 entry.fail( 159 'Entry `%s` does not have required fields for string replacement.' 160 % entry['title'] 161 ) 162 continue 163 164 logger.debug( 165 'phase_name: {} operation: {} cmd: {}', phase_name, operation, cmd 166 ) 167 if task.options.test: 168 logger.info('Would execute: {}', cmd) 169 else: 170 # Make sure the command can be encoded into appropriate encoding, don't actually encode yet, 171 # so logging continues to work. 172 try: 173 cmd.encode(config['encoding']) 174 except UnicodeEncodeError: 175 logger.error( 176 'Unable to encode cmd `{}` to {}', cmd, config['encoding'] 177 ) 178 if config.get('fail_entries'): 179 entry.fail( 180 'cmd `%s` could not be encoded to %s.' 181 % (cmd, config['encoding']) 182 ) 183 continue 184 # Run the command, fail entries with non-zero return code if configured to 185 if self.execute_cmd( 186 cmd, allow_background, config['encoding'] 187 ) != 0 and config.get('fail_entries'): 188 entry.fail('exec return code was non-zero') 189 190 # phase keyword in this 191 if 'phase' in config[phase_name]: 192 for cmd in config[phase_name]['phase']: 193 try: 194 cmd = render_from_task(cmd, task) 195 except RenderError as e: 196 logger.error('Error rendering `{}`: {}', cmd, e) 197 else: 198 logger.debug('phase cmd: {}', cmd) 199 if task.options.test: 200 logger.info('Would execute: {}', cmd) 201 else: 202 self.execute_cmd(cmd, allow_background, config['encoding']) 203 204 def __getattr__(self, item): 205 """Creates methods to handle task phases.""" 206 for phase in self.HANDLED_PHASES: 207 if item == plugin.phase_methods[phase]: 208 # A phase method we handle has been requested 209 break 210 else: 211 # We don't handle this phase 212 raise AttributeError(item) 213 214 def phase_handler(task, config): 215 self.execute(task, 'on_' + phase, config) 216 217 # Make sure we run after other plugins so exec can use their output 218 phase_handler.priority = 100 219 return phase_handler 220 221 222@event('plugin.register') 223def register_plugin(): 224 plugin.register(PluginExec, 'exec', api_ver=2) 225