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