Source code for flexget.plugins.output.exec

import subprocess

from loguru import logger

from flexget import plugin
from flexget.config_schema import one_or_more
from flexget.entry import Entry
from flexget.event import event
from flexget.utils.template import RenderError, render_from_entry, render_from_task
from flexget.utils.tools import io_encoding

logger = logger.bind(name='exec')


[docs] class EscapingEntry(Entry): """Helper class, same as a Entry, but returns all string value with quotes escaped.""" def __init__(self, entry): super().__init__(entry) def __getitem__(self, key): value = super().__getitem__(key) # TODO: May need to be different depending on OS if isinstance(value, str): value = value.replace('"', '\\"') return value
[docs] class PluginExec: """Execute commands. Simple example, xecute command for entries that reach output:: exec: echo 'found {{title}} at {{url}}' > file Advanced Example:: exec: on_start: phase: echo "Started" on_input: for_entries: echo 'got {{title}}' on_output: for_accepted: echo 'accepted {{title}} - {{url}} > file You can use all (available) entry fields in the command. """ NAME = 'exec' HANDLED_PHASES = ['start', 'input', 'filter', 'output', 'exit'] schema = { 'oneOf': [ one_or_more({'type': 'string'}), { 'type': 'object', 'properties': { 'on_start': {'$ref': '#/$defs/phaseSettings'}, 'on_input': {'$ref': '#/$defs/phaseSettings'}, 'on_filter': {'$ref': '#/$defs/phaseSettings'}, 'on_output': {'$ref': '#/$defs/phaseSettings'}, 'on_exit': {'$ref': '#/$defs/phaseSettings'}, 'fail_entries': {'type': 'boolean'}, 'auto_escape': {'type': 'boolean'}, 'encoding': {'type': 'string'}, 'allow_background': {'type': 'boolean'}, }, 'additionalProperties': False, }, ], '$defs': { 'phaseSettings': { 'type': 'object', 'properties': { 'phase': one_or_more({'type': 'string'}), 'for_entries': one_or_more({'type': 'string'}), 'for_accepted': one_or_more({'type': 'string'}), 'for_rejected': one_or_more({'type': 'string'}), 'for_undecided': one_or_more({'type': 'string'}), 'for_failed': one_or_more({'type': 'string'}), }, 'additionalProperties': False, } }, }
[docs] def prepare_config(self, config): if isinstance(config, str): config = [config] if isinstance(config, list): config = {'on_output': {'for_accepted': config}} if not config.get('encoding'): config['encoding'] = io_encoding for phase_name in config: if phase_name.startswith('on_'): for items_name in config[phase_name]: if isinstance(config[phase_name][items_name], str): config[phase_name][items_name] = [config[phase_name][items_name]] return config
[docs] def execute_cmd(self, cmd, allow_background, encoding): logger.verbose('Executing: {}', cmd) p = subprocess.Popen( cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=False, ) if not allow_background: r, w = (p.stdout, p.stdin) response = r.read().decode(io_encoding) r.close() w.close() if response: logger.info('Stdout: {}', response.rstrip()) # rstrip to get rid of newlines return p.wait()
[docs] def execute(self, task, phase_name, config): config = self.prepare_config(config) if phase_name not in config: logger.debug('phase {} not configured', phase_name) return name_map = { 'for_entries': task.entries, 'for_accepted': task.accepted, 'for_rejected': task.rejected, 'for_undecided': task.undecided, 'for_failed': task.failed, } allow_background = config.get('allow_background') for operation, entries in name_map.items(): if operation not in config[phase_name]: continue logger.debug( 'running phase_name: {} operation: {} entries: {}', phase_name, operation, len(entries), ) for entry in entries: for cmd in config[phase_name][operation]: entrydict = EscapingEntry(entry) if config.get('auto_escape') else entry # Do string replacement from entry, but make sure quotes get escaped try: cmd = render_from_entry(cmd, entrydict) except RenderError as e: logger.error('Could not set exec command for {}: {}', entry['title'], e) # fail the entry if configured to do so if config.get('fail_entries'): entry.fail( 'Entry `{}` does not have required fields for string replacement.'.format( entry['title'] ) ) continue logger.debug( 'phase_name: {} operation: {} cmd: {}', phase_name, operation, cmd ) if task.options.test: logger.info('Would execute: {}', cmd) else: # Make sure the command can be encoded into appropriate encoding, don't actually encode yet, # so logging continues to work. try: cmd.encode(config['encoding']) except UnicodeEncodeError: logger.error( 'Unable to encode cmd `{}` to {}', cmd, config['encoding'] ) if config.get('fail_entries'): entry.fail( 'cmd `{}` could not be encoded to {}.'.format( cmd, config['encoding'] ) ) continue # Run the command, fail entries with non-zero return code if configured to if self.execute_cmd( cmd, allow_background, config['encoding'] ) != 0 and config.get('fail_entries'): entry.fail('exec return code was non-zero') # phase keyword in this if 'phase' in config[phase_name]: for cmd in config[phase_name]['phase']: try: cmd = render_from_task(cmd, task) except RenderError as e: logger.error('Error rendering `{}`: {}', cmd, e) else: logger.debug('phase cmd: {}', cmd) if task.options.test: logger.info('Would execute: {}', cmd) else: self.execute_cmd(cmd, allow_background, config['encoding'])
def __getattr__(self, item): """Create methods to handle task phases.""" for phase in self.HANDLED_PHASES: if item == plugin.phase_methods[phase]: # A phase method we handle has been requested break else: # We don't handle this phase raise AttributeError(item) def phase_handler(task, config): self.execute(task, 'on_' + phase, config) # Make sure we run after other plugins so exec can use their output phase_handler.priority = 100 return phase_handler
[docs] @event('plugin.register') def register_plugin(): plugin.register(PluginExec, 'exec', api_ver=2)