From 88ece413f3aa98f5880bc6cccdbd574d8272eb0c Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 04:49:50 +0100 Subject: [PATCH 01/23] Add Hook plugin to run commands on events This plugin allows users to execute scripts on different events, as well as forward any arguments from the events to the script. --- beetsplug/hook.py | 100 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 beetsplug/hook.py diff --git a/beetsplug/hook.py b/beetsplug/hook.py new file mode 100644 index 0000000000..98ab73239b --- /dev/null +++ b/beetsplug/hook.py @@ -0,0 +1,100 @@ +# This file is part of beets. +# Copyright 2015, Adrian Sampson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Allows custom commands to be run when an event is emitted by beets""" +from __future__ import (division, absolute_import, print_function, + unicode_literals) + +import subprocess +import sys + +from beets.plugins import BeetsPlugin + + +def create_hook_function(command, shell, substitute_args): + + # TODO: Find a better way of piping STDOUT/STDERR/STDIN between the process + # and the user. + # + # The issue with our current method is that we can only pesudo-pipe + # one (two if we count STDERR being piped to STDOUT) stream at a + # time, meaning we can't have both output and input simultaneously. + # This is due to how Popen.std(out/err) works, as + # Popen.std(out/err).readline() waits until a newline has been output + # to the stream before returning. + + def hook_function(**kwargs): + hook_command = command + + for key in kwargs: + if key in substitute_args: + hook_command = hook_command.replace(substitute_args[key], + str(kwargs[key])) + + process = subprocess.Popen(hook_command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + shell=shell) + + while process.poll() is None: + sys.stdout.write(process.stdout.readline()) + + # Ensure there's nothing left in the stream + sys.stdout.write(process.stdout.readline()) + + return hook_function + + +class HookPlugin(BeetsPlugin): + """Allows custom commands to be run when an event is emitted by beets""" + def __init__(self): + super(HookPlugin, self).__init__() + + self.config.add({ + 'hooks': [], + 'substitute_event': '%EVENT%', + 'shell': True + }) + + hooks = self.config['hooks'].get(list) + global_substitute_event = self.config['substitute_event'].get() + global_shell = self.config['shell'].get(bool) + + for hook_index in range(len(hooks)): + hook = self.config['hooks'][hook_index] + + hook_event = hook['event'].get() + hook_command = hook['command'].get() + + if 'substitute_event' in hook: + original = hook['substitute_event'].get() + else: + original = global_substitute_event + + if 'shell' in hook: + shell = hook['shell'].get(bool) + else: + shell = global_shell + + if 'substitute_args' in hook: + substitute_args = hook['substitute_args'].get(dict) + else: + substitute_args = {} + + hook_command = hook_command.replace(original, hook_event) + hook_function = create_hook_function(hook_command, + shell, + substitute_args) + + self.register_listener(hook_event, hook_function) From 2d0c217252700af696bc3315ac9db56c3650032a Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 15:16:44 +0100 Subject: [PATCH 02/23] Improve the way substitute_args is iterated Iterate substitute_args instead of kwargs, as we ignore anything that is not in substitute_args already. Fix an issue where a hook argument containing non-ascii characters caused an exception. --- beetsplug/hook.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 98ab73239b..c0747bce5e 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -34,13 +34,18 @@ def create_hook_function(command, shell, substitute_args): # Popen.std(out/err).readline() waits until a newline has been output # to the stream before returning. + # TODO: Find a better way of converting arguments to strings, as I + # currently have a feeling that forcing everything to utf-8 might + # end up causing a mess. + def hook_function(**kwargs): hook_command = command - for key in kwargs: - if key in substitute_args: + for key in substitute_args: + if key in kwargs: hook_command = hook_command.replace(substitute_args[key], - str(kwargs[key])) + unicode(kwargs[key], + "utf-8")) process = subprocess.Popen(hook_command, stdout=subprocess.PIPE, From 1378351c07d57f1e08635f20a98ee6874c4545da Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 15:24:29 +0100 Subject: [PATCH 03/23] Add documentation for hook plugin --- docs/plugins/hook.rst | 77 ++++++++++++++++++++++++++++++++++++++++++ docs/plugins/index.rst | 2 ++ 2 files changed, 79 insertions(+) create mode 100644 docs/plugins/hook.rst diff --git a/docs/plugins/hook.rst b/docs/plugins/hook.rst new file mode 100644 index 0000000000..25010f111f --- /dev/null +++ b/docs/plugins/hook.rst @@ -0,0 +1,77 @@ +Hook Plugin +=============== + +Internally, beets sends events to plugins when an action finishes. These can +range from importing a song (``import``) to beets exiting (``cli_exit``), and +provide a very flexible way to perform actions based on the events. This plugin +allows you to run commands when an event is emitted by beets, such as syncing +your library with another drive when the library is updated. + +Hooks are currently run in the order defined in the configuration, however this +is dependent on beets itself (may change in the future) and cannot be controlled +by this plugin. + +.. _hook-configuration: + +Configuration +------------- + +To configure the plugin, make a ``hook:`` section in your configuration +file. The available options are: + +- **hooks**: A list of events and the commands to run (see :ref:`individual-hook-configuration`) + Default: Empty. +- **substitute_event**: The string to replace in each command with the name of + the event executing it. This can be used to allow one script to act + differently depending on the event it was called by. Can be individually + overridden (see :ref:`individual-hook-configuration`). + Default: ``%EVENT%`` +- **shell**: Run each command in a shell. Can be individually + overridden (see :ref:`individual-hook-configuration`). + Default: ``yes`` + +.. _individual-hook-configuration: + +Individual Hook Configuration +----------------------------- + +Each element of the ``hooks`` configuration option can be configured separately. +The available options are: + +- **event** (required): The name of the event that should cause this hook to execute. See + :ref:`Plugin Events ` for a list of possible values. +- **command** (required): The command to run when this hook executes. +- **substitute_event**: Hook-level override for ``substitute_event`` option in + :ref:`hook-configuration`. + Default: Value of top level ``substitute_event`` option (see :ref:`hook-configuration`) +- **shell**: Hook-level override for ``shell`` option in :ref:`hook-configuration`. + Default: Value of top level ``shell`` option (see :ref:`hook-configuration`). +- **substitute_args**: A key/value set where the key is the name of the an + argument passed to the event (see :ref:`Plugin Events ` for + a list of arguments for each event) and the value is the string to replace + in the command with the value of the argument. Note that any arguments that + are not strings will be converted to strings (e.g. Python objects). + Default: Empty. + +Example Configuration +--------------------- + +.. code-block:: yaml + + hook: + hooks: + # Output on exit: + # beets just exited! + # have a nice day! + - event: cli_exit + command: echo "beets just exited!" + - event: cli_exit + command: echo "have a nice day!" + + # Output on write + # writing to "" + # Where is the file being written to + - event: write + command echo "writing to \"%FILE_NAME%\"" + substitute_args: + path: %FILE_NAME% diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 3f9fbb7af7..cc8a58c2ee 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -46,6 +46,7 @@ Each plugin has its own set of options that can be defined in a section bearing ftintitle fuzzy freedesktop + hook ihate importadded importfeeds @@ -154,6 +155,7 @@ Miscellaneous a different directory. * :doc:`duplicates`: List duplicate tracks or albums. * :doc:`fuzzy`: Search albums and tracks with fuzzy string matching. +* :doc:`hook`: Run a command when an event is emitted by beets. * :doc:`ihate`: Automatically skip albums and tracks during the import process. * :doc:`info`: Print music files' tags to the console. * :doc:`mbcollection`: Maintain your MusicBrainz collection list. From 8fea1e65c50c4366ce166690d4eb09a297efe070 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 18:01:36 +0100 Subject: [PATCH 04/23] Add logging for hook plugin --- beetsplug/hook.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index c0747bce5e..5d15a55aa6 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -22,7 +22,7 @@ from beets.plugins import BeetsPlugin -def create_hook_function(command, shell, substitute_args): +def create_hook_function(log, event, command, shell, substitute_args): # TODO: Find a better way of piping STDOUT/STDERR/STDIN between the process # and the user. @@ -47,6 +47,8 @@ def hook_function(**kwargs): unicode(kwargs[key], "utf-8")) + log.debug('Running command {0} for event {1}', hook_command, event) + process = subprocess.Popen(hook_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -98,8 +100,8 @@ def __init__(self): substitute_args = {} hook_command = hook_command.replace(original, hook_event) - hook_function = create_hook_function(hook_command, - shell, - substitute_args) + hook_function = create_hook_function(self._log, hook_event, + hook_command, shell, + substitute_args) self.register_listener(hook_event, hook_function) From 0af47bff446fb5477a4e554f00138e9efc14c1d9 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 18:08:54 +0100 Subject: [PATCH 05/23] Add tests for hook plugin --- test/test_hook.py | 134 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 test/test_hook.py diff --git a/test/test_hook.py b/test/test_hook.py new file mode 100644 index 0000000000..7f6665ec27 --- /dev/null +++ b/test/test_hook.py @@ -0,0 +1,134 @@ +# This file is part of beets. +# Copyright 2015, Thomas Scholtes. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +from __future__ import (division, absolute_import, print_function, + unicode_literals) + +import os.path +import tempfile + +from test import _common +from test._common import unittest +from test.helper import TestHelper + +from beets import config, logging +from beets import plugins + + +def get_temporary_path(): + temporary_directory = tempfile._get_default_tempdir() + temporary_name = next(tempfile._get_candidate_names()) + + return os.path.join(temporary_directory, temporary_name) + + +# TODO: Find a good way to test shell option +class HookTest(_common.TestCase, TestHelper): + TEST_HOOK_COUNT = 5 + + def setUp(self): + self.setup_beets() # Converter is threaded + + def tearDown(self): + self.unload_plugins() + self.teardown_beets() + + def _add_hook(self, event, command, substitute_event=None, shell=None, + substitute_args=None): + + hook = { + 'event': event, + 'command': command + } + + if substitute_event is not None: + hook['substitute_event'] = substitute_event + + if shell is not None: + hook['shell'] = shell + + if substitute_args is not None: + hook['substitute_args'] = substitute_args + + hooks = config['hook']['hooks'].get(list) if 'hook' in config else [] + hooks.append(hook) + + config['hook']['hooks'] = hooks + + def test_hook_no_arguments(self): + temporary_paths = [ + get_temporary_path() for i in range(self.TEST_HOOK_COUNT) + ] + + for index, path in enumerate(temporary_paths): + self._add_hook('test_event_{0}'.format(index), + 'echo > "{0}"'.format(path)) + + self.load_plugins('hook') + + for index in range(len(temporary_paths)): + plugins.send('test_event_{0}'.format(index)) + + for path in temporary_paths: + self.assertTrue(os.path.isfile(path)) + os.remove(path) + + def test_hook_event_substitution(self): + + temporary_directory = tempfile._get_default_tempdir() + event_names = ['test_event_{0}'.format(i) for i in + range(self.TEST_HOOK_COUNT)] + + for event in event_names: + self._add_hook(event, + 'echo > "{0}"'.format( + os.path.join(temporary_directory, '%EVENT%') + )) + + self.load_plugins('hook') + + for event in event_names: + plugins.send(event) + + for event in event_names: + path = os.path.join(temporary_directory, event) + + self.assertTrue(os.path.isfile(path)) + os.remove(path) + + def test_hook_argument_substitution(self): + temporary_paths = [ + get_temporary_path() for i in range(self.TEST_HOOK_COUNT) + ] + + for index, path in enumerate(temporary_paths): + self._add_hook('test_event_{0}'.format(index), + 'echo > "%PATH%"'.format(path), + substitute_args = { 'path': '%PATH%' }) + + self.load_plugins('hook') + + for index, path in enumerate(temporary_paths): + plugins.send('test_event_{0}'.format(index), path = path) + + for path in temporary_paths: + self.assertTrue(os.path.isfile(path)) + os.remove(path) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == b'__main__': + unittest.main(defaultTest='suite') From 6a56677c7cc65c46408d9773f5b04d0142c67c10 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 18:23:15 +0100 Subject: [PATCH 06/23] Move line break to correct position --- docs/plugins/hook.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/hook.rst b/docs/plugins/hook.rst index 25010f111f..4d0c5d6b94 100644 --- a/docs/plugins/hook.rst +++ b/docs/plugins/hook.rst @@ -26,8 +26,8 @@ file. The available options are: differently depending on the event it was called by. Can be individually overridden (see :ref:`individual-hook-configuration`). Default: ``%EVENT%`` -- **shell**: Run each command in a shell. Can be individually - overridden (see :ref:`individual-hook-configuration`). +- **shell**: Run each command in a shell. Can be individually overridden (see + :ref:`individual-hook-configuration`). Default: ``yes`` .. _individual-hook-configuration: From 553dd1f39dbcb21c3bf4e088f1a12715dce9eeae Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 18:23:29 +0100 Subject: [PATCH 07/23] Add missing colon for configuration --- docs/plugins/hook.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/hook.rst b/docs/plugins/hook.rst index 4d0c5d6b94..9ede5e998d 100644 --- a/docs/plugins/hook.rst +++ b/docs/plugins/hook.rst @@ -72,6 +72,6 @@ Example Configuration # writing to "" # Where is the file being written to - event: write - command echo "writing to \"%FILE_NAME%\"" + command: echo "writing to \"%FILE_NAME%\"" substitute_args: path: %FILE_NAME% From 276c5da913ba2cb121de81986386197c0098b0f2 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 18:37:20 +0100 Subject: [PATCH 08/23] Remove unused logging import --- test/test_hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_hook.py b/test/test_hook.py index 7f6665ec27..0a9ff8b1bf 100644 --- a/test/test_hook.py +++ b/test/test_hook.py @@ -22,7 +22,7 @@ from test._common import unittest from test.helper import TestHelper -from beets import config, logging +from beets import config from beets import plugins From 8b7af7fe23dffef479e817d05e329472c7c88d56 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 18:38:30 +0100 Subject: [PATCH 09/23] Fix indentation for wrapped method call --- beetsplug/hook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 5d15a55aa6..ed6fa4e931 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -101,7 +101,7 @@ def __init__(self): hook_command = hook_command.replace(original, hook_event) hook_function = create_hook_function(self._log, hook_event, - hook_command, shell, - substitute_args) + hook_command, shell, + substitute_args) self.register_listener(hook_event, hook_function) From 46e5f9d4c879f05a37afcd0f1ff81ed993d74f0a Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 18:39:12 +0100 Subject: [PATCH 10/23] Update tests to follow PEP 8 coding style --- test/test_hook.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_hook.py b/test/test_hook.py index 0a9ff8b1bf..f6b8b49417 100644 --- a/test/test_hook.py +++ b/test/test_hook.py @@ -115,12 +115,12 @@ def test_hook_argument_substitution(self): for index, path in enumerate(temporary_paths): self._add_hook('test_event_{0}'.format(index), 'echo > "%PATH%"'.format(path), - substitute_args = { 'path': '%PATH%' }) + substitute_args={'path': '%PATH%'}) self.load_plugins('hook') - for index, path in enumerate(temporary_paths): - plugins.send('test_event_{0}'.format(index), path = path) + for index, path in enumerate(temporary_paths): + plugins.send('test_event_{0}'.format(index), path=path) for path in temporary_paths: self.assertTrue(os.path.isfile(path)) From 3d058f4b6c66dd9915650f783a7f7187e6c3f96f Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 11 Sep 2015 20:36:12 +0100 Subject: [PATCH 11/23] Changelog for #1603 --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index dec5bc28a9..1df9f45d3a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,8 @@ The new features: trigger a "lots of music" warning. :bug:`1577` * :doc:`/plugins/plexupdate`: A new ``library_name`` option allows you to select which Plex library to update. :bug:`1572` :bug:`1595` +* New :doc:`/plugins/hook` that allows commands to be executed when an event is + emitted by beets. :bug:`1561` :bug:`1603` Fixes: From ae2ff6185fa4b11a8fbbd8aebfaf1a037b9aa205 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Sat, 12 Sep 2015 02:09:19 +0100 Subject: [PATCH 12/23] Use default stdout and stderr streams for hook processes --- beetsplug/hook.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index ed6fa4e931..67595a1b31 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -50,15 +50,9 @@ def hook_function(**kwargs): log.debug('Running command {0} for event {1}', hook_command, event) process = subprocess.Popen(hook_command, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, shell=shell) - while process.poll() is None: - sys.stdout.write(process.stdout.readline()) - - # Ensure there's nothing left in the stream - sys.stdout.write(process.stdout.readline()) + process.wait() return hook_function From 0dff24eb96870de8479ca1b21405311a47fa236b Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Sat, 12 Sep 2015 02:14:33 +0100 Subject: [PATCH 13/23] Move Popen call to a single line --- beetsplug/hook.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 67595a1b31..95d330ac10 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -49,10 +49,7 @@ def hook_function(**kwargs): log.debug('Running command {0} for event {1}', hook_command, event) - process = subprocess.Popen(hook_command, - shell=shell) - - process.wait() + subprocess.Popen(hook_command, shell=shell).wait() return hook_function From 417a724e42017952dd6c326d5d35807ba724b9b9 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Sat, 12 Sep 2015 02:15:11 +0100 Subject: [PATCH 14/23] Remove unused sys import and use correct platform encoding --- beetsplug/hook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 95d330ac10..af411f20f4 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -17,8 +17,8 @@ unicode_literals) import subprocess -import sys +from beets.ui import _arg_encoding from beets.plugins import BeetsPlugin @@ -45,7 +45,7 @@ def hook_function(**kwargs): if key in kwargs: hook_command = hook_command.replace(substitute_args[key], unicode(kwargs[key], - "utf-8")) + _arg_encoding())) log.debug('Running command {0} for event {1}', hook_command, event) From 55bd513278caedc3b0e1bd2662dc6fdf41c95df0 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Sat, 12 Sep 2015 02:16:22 +0100 Subject: [PATCH 15/23] Remove completed TODO comments --- beetsplug/hook.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index af411f20f4..7925b16c8a 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -23,21 +23,6 @@ def create_hook_function(log, event, command, shell, substitute_args): - - # TODO: Find a better way of piping STDOUT/STDERR/STDIN between the process - # and the user. - # - # The issue with our current method is that we can only pesudo-pipe - # one (two if we count STDERR being piped to STDOUT) stream at a - # time, meaning we can't have both output and input simultaneously. - # This is due to how Popen.std(out/err) works, as - # Popen.std(out/err).readline() waits until a newline has been output - # to the stream before returning. - - # TODO: Find a better way of converting arguments to strings, as I - # currently have a feeling that forcing everything to utf-8 might - # end up causing a mess. - def hook_function(**kwargs): hook_command = command From 8b4f349e27279ffa0f09e63a7649a85e3c2a0232 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Mon, 18 Apr 2016 15:04:57 +0100 Subject: [PATCH 16/23] Improve hook plugin design and configuration - Remove `shell` option and split all commands using `shlex.split` before passing them to `subprocess.Popen`. - General refactor of hook plugin code - move hook creation function inside `HookPlugin`. - Add improved error handling for invalid (i.e. empty) commands or commands that do not exist. --- beetsplug/hook.py | 65 ++++++++++++++++++----------------------------- 1 file changed, 25 insertions(+), 40 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 7925b16c8a..0428831dcf 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -16,27 +16,13 @@ from __future__ import (division, absolute_import, print_function, unicode_literals) +import shlex import subprocess +import sys -from beets.ui import _arg_encoding from beets.plugins import BeetsPlugin - - -def create_hook_function(log, event, command, shell, substitute_args): - def hook_function(**kwargs): - hook_command = command - - for key in substitute_args: - if key in kwargs: - hook_command = hook_command.replace(substitute_args[key], - unicode(kwargs[key], - _arg_encoding())) - - log.debug('Running command {0} for event {1}', hook_command, event) - - subprocess.Popen(hook_command, shell=shell).wait() - - return hook_function +from beets.ui import _arg_encoding +from beets.util.confit import ConfigValueError class HookPlugin(BeetsPlugin): @@ -45,14 +31,10 @@ def __init__(self): super(HookPlugin, self).__init__() self.config.add({ - 'hooks': [], - 'substitute_event': '%EVENT%', - 'shell': True + 'hooks': [] }) hooks = self.config['hooks'].get(list) - global_substitute_event = self.config['substitute_event'].get() - global_shell = self.config['shell'].get(bool) for hook_index in range(len(hooks)): hook = self.config['hooks'][hook_index] @@ -60,24 +42,27 @@ def __init__(self): hook_event = hook['event'].get() hook_command = hook['command'].get() - if 'substitute_event' in hook: - original = hook['substitute_event'].get() - else: - original = global_substitute_event + self.create_and_register_hook(hook_event, hook_command) + + def create_and_register_hook(self, event, command): + def hook_function(**kwargs): + formatted_command = command.format(event=event, **kwargs) + encoded_command = formatted_command.decode(_arg_encoding()) + command_pieces = shlex.split(encoded_command) + + if len(command_pieces) == 0: + raise ConfigValueError('invalid command \"{0}\"'.format( + command)) - if 'shell' in hook: - shell = hook['shell'].get(bool) - else: - shell = global_shell + self._log.debug('Running command \"{0}\" for event \"{1}\"', + encoded_command, event) - if 'substitute_args' in hook: - substitute_args = hook['substitute_args'].get(dict) - else: - substitute_args = {} + try: + subprocess.Popen(command_pieces).wait() + except OSError as e: + _, _, trace = sys.exc_info() + message = "{0}: {1}".format(e, command_pieces[0]) - hook_command = hook_command.replace(original, hook_event) - hook_function = create_hook_function(self._log, hook_event, - hook_command, shell, - substitute_args) + raise OSError, message, trace - self.register_listener(hook_event, hook_function) + self.register_listener(event, hook_function) From 686e069bc49b179c28104f63e8e897cff3ae8adf Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Mon, 18 Apr 2016 15:35:15 +0100 Subject: [PATCH 17/23] Replace double quotes with single quotes --- beetsplug/hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 0428831dcf..a863956405 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -61,7 +61,7 @@ def hook_function(**kwargs): subprocess.Popen(command_pieces).wait() except OSError as e: _, _, trace = sys.exc_info() - message = "{0}: {1}".format(e, command_pieces[0]) + message = '{0}: {1}'.format(e, command_pieces[0]) raise OSError, message, trace From 3e35660ff31ededb00036877690a75e07979f681 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Mon, 18 Apr 2016 15:36:05 +0100 Subject: [PATCH 18/23] Remove unnecessary escaping on double quotes --- beetsplug/hook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index a863956405..dad6779cea 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -51,10 +51,10 @@ def hook_function(**kwargs): command_pieces = shlex.split(encoded_command) if len(command_pieces) == 0: - raise ConfigValueError('invalid command \"{0}\"'.format( + raise ConfigValueError('invalid command "{0}"'.format( command)) - self._log.debug('Running command \"{0}\" for event \"{1}\"', + self._log.debug('Running command "{0}" for event "{1}"', encoded_command, event) try: From af5ce6e7e2cbad8250fa0a87618c79d80f2bc65c Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Mon, 18 Apr 2016 15:36:25 +0100 Subject: [PATCH 19/23] Fix event name collision in tests and update tests - Fix `test_event_X` name collision between tests causing tests to fail unexpectedly. - Update tests to match new hook plugin design (i.e. remove shell and subtitution option testing). --- test/test_hook.py | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/test/test_hook.py b/test/test_hook.py index f6b8b49417..6b046e8efb 100644 --- a/test/test_hook.py +++ b/test/test_hook.py @@ -33,7 +33,6 @@ def get_temporary_path(): return os.path.join(temporary_directory, temporary_name) -# TODO: Find a good way to test shell option class HookTest(_common.TestCase, TestHelper): TEST_HOOK_COUNT = 5 @@ -44,23 +43,12 @@ def tearDown(self): self.unload_plugins() self.teardown_beets() - def _add_hook(self, event, command, substitute_event=None, shell=None, - substitute_args=None): - + def _add_hook(self, event, command): hook = { 'event': event, 'command': command } - if substitute_event is not None: - hook['substitute_event'] = substitute_event - - if shell is not None: - hook['shell'] = shell - - if substitute_args is not None: - hook['substitute_args'] = substitute_args - hooks = config['hook']['hooks'].get(list) if 'hook' in config else [] hooks.append(hook) @@ -72,29 +60,26 @@ def test_hook_no_arguments(self): ] for index, path in enumerate(temporary_paths): - self._add_hook('test_event_{0}'.format(index), - 'echo > "{0}"'.format(path)) + self._add_hook('test_no_argument_event_{0}'.format(index), + 'touch "{0}"'.format(path)) self.load_plugins('hook') for index in range(len(temporary_paths)): - plugins.send('test_event_{0}'.format(index)) + plugins.send('test_no_argument_event_{0}'.format(index)) for path in temporary_paths: self.assertTrue(os.path.isfile(path)) os.remove(path) def test_hook_event_substitution(self): - temporary_directory = tempfile._get_default_tempdir() - event_names = ['test_event_{0}'.format(i) for i in + event_names = ['test_event_event_{0}'.format(i) for i in range(self.TEST_HOOK_COUNT)] for event in event_names: self._add_hook(event, - 'echo > "{0}"'.format( - os.path.join(temporary_directory, '%EVENT%') - )) + 'touch "{0}/{{event}}"'.format(temporary_directory)) self.load_plugins('hook') @@ -113,14 +98,13 @@ def test_hook_argument_substitution(self): ] for index, path in enumerate(temporary_paths): - self._add_hook('test_event_{0}'.format(index), - 'echo > "%PATH%"'.format(path), - substitute_args={'path': '%PATH%'}) + self._add_hook('test_argument_event_{0}'.format(index), + 'touch "{path}"') self.load_plugins('hook') for index, path in enumerate(temporary_paths): - plugins.send('test_event_{0}'.format(index), path=path) + plugins.send('test_argument_event_{0}'.format(index), path=path) for path in temporary_paths: self.assertTrue(os.path.isfile(path)) From 070469e259226b485a062040bf042863d7f65489 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Mon, 18 Apr 2016 16:00:13 +0100 Subject: [PATCH 20/23] Remove unicode_literals from __future__ imports --- beetsplug/hook.py | 3 +-- test/test_hook.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index dad6779cea..a32247bf84 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -13,8 +13,7 @@ # included in all copies or substantial portions of the Software. """Allows custom commands to be run when an event is emitted by beets""" -from __future__ import (division, absolute_import, print_function, - unicode_literals) +from __future__ import division, absolute_import, print_function import shlex import subprocess diff --git a/test/test_hook.py b/test/test_hook.py index 6b046e8efb..fc4e7a7eef 100644 --- a/test/test_hook.py +++ b/test/test_hook.py @@ -12,8 +12,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -from __future__ import (division, absolute_import, print_function, - unicode_literals) +from __future__ import division, absolute_import, print_function import os.path import tempfile From 028c78adc80968c5e6d8145669fc4dbfb3a82e3d Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Mon, 18 Apr 2016 16:05:39 +0100 Subject: [PATCH 21/23] Update documentation to match new hook plugin design --- docs/plugins/hook.rst | 61 +++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/docs/plugins/hook.rst b/docs/plugins/hook.rst index 9ede5e998d..2de736c7c3 100644 --- a/docs/plugins/hook.rst +++ b/docs/plugins/hook.rst @@ -8,27 +8,18 @@ allows you to run commands when an event is emitted by beets, such as syncing your library with another drive when the library is updated. Hooks are currently run in the order defined in the configuration, however this -is dependent on beets itself (may change in the future) and cannot be controlled -by this plugin. +is dependent on beets itself and it's consistency should not be depended upon. .. _hook-configuration: Configuration ------------- -To configure the plugin, make a ``hook:`` section in your configuration +To configure the plugin, make a ``hook`` section in your configuration file. The available options are: -- **hooks**: A list of events and the commands to run (see :ref:`individual-hook-configuration`) - Default: Empty. -- **substitute_event**: The string to replace in each command with the name of - the event executing it. This can be used to allow one script to act - differently depending on the event it was called by. Can be individually - overridden (see :ref:`individual-hook-configuration`). - Default: ``%EVENT%`` -- **shell**: Run each command in a shell. Can be individually overridden (see - :ref:`individual-hook-configuration`). - Default: ``yes`` +- **hooks**: A list of events and the commands to run + (see :ref:`individual-hook-configuration`). Default: Empty. .. _individual-hook-configuration: @@ -38,20 +29,24 @@ Individual Hook Configuration Each element of the ``hooks`` configuration option can be configured separately. The available options are: -- **event** (required): The name of the event that should cause this hook to execute. See - :ref:`Plugin Events ` for a list of possible values. -- **command** (required): The command to run when this hook executes. -- **substitute_event**: Hook-level override for ``substitute_event`` option in - :ref:`hook-configuration`. - Default: Value of top level ``substitute_event`` option (see :ref:`hook-configuration`) -- **shell**: Hook-level override for ``shell`` option in :ref:`hook-configuration`. - Default: Value of top level ``shell`` option (see :ref:`hook-configuration`). -- **substitute_args**: A key/value set where the key is the name of the an - argument passed to the event (see :ref:`Plugin Events ` for - a list of arguments for each event) and the value is the string to replace - in the command with the value of the argument. Note that any arguments that - are not strings will be converted to strings (e.g. Python objects). - Default: Empty. +- **event**: The name of the event that should cause this hook to + execute. See the :ref:`plugin events ` documentation for a list + of possible values. +- **command**: The command to run when this hook executes. + +.. _command-substitution: + +Command Substitution +-------------------- + +Certain key words can be replaced in commands, allowing access to event +information such as the path of an album or the name of a song. This information +is accessed using the syntax ``{property_name}``, where ``property_name`` is the +name of an argument passed to the event. ``property_name`` can also be a key on +an argument passed to the event, such as ``{album.path}``. + +You can find a list of all available events and their arguments in the +:ref:`plugin events ` documentation. Example Configuration --------------------- @@ -68,10 +63,14 @@ Example Configuration - event: cli_exit command: echo "have a nice day!" - # Output on write + # Output on item import: + # importing "" + # Where is the item being imported + - event: item_imported + command: echo "importing \"{item.path}\"" + + # Output on write: # writing to "" # Where is the file being written to - event: write - command: echo "writing to \"%FILE_NAME%\"" - substitute_args: - path: %FILE_NAME% + command: echo "writing to {path}" From dea091ee5370dfd3af956b93132d60efdea07157 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Mon, 18 Apr 2016 19:16:31 +0100 Subject: [PATCH 22/23] Improve error handling for invalid commands --- beetsplug/hook.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index a32247bf84..4f38a7b9c4 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -45,23 +45,20 @@ def __init__(self): def create_and_register_hook(self, event, command): def hook_function(**kwargs): + if command is None or len(command) == 0: + self._log.error('invalid command "{0}"', command) + return + formatted_command = command.format(event=event, **kwargs) encoded_command = formatted_command.decode(_arg_encoding()) command_pieces = shlex.split(encoded_command) - if len(command_pieces) == 0: - raise ConfigValueError('invalid command "{0}"'.format( - command)) - - self._log.debug('Running command "{0}" for event "{1}"', + self._log.debug('Running command "{0}" for event {1}', encoded_command, event) try: subprocess.Popen(command_pieces).wait() - except OSError as e: - _, _, trace = sys.exc_info() - message = '{0}: {1}'.format(e, command_pieces[0]) - - raise OSError, message, trace + except OSError as exc: + self._log.error('hook for {0} failed: {1}', event, exc) self.register_listener(event, hook_function) From 85fd60852fdf5f927cd1060fca7c5b8316db4269 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Mon, 18 Apr 2016 19:21:31 +0100 Subject: [PATCH 23/23] Remove unused dependencies --- beetsplug/hook.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 4f38a7b9c4..81fb9a7f9e 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -17,11 +17,9 @@ import shlex import subprocess -import sys from beets.plugins import BeetsPlugin from beets.ui import _arg_encoding -from beets.util.confit import ConfigValueError class HookPlugin(BeetsPlugin):