diff --git a/picard/extension_points/formats.py b/picard/extension_points/formats.py index 97e8aa6fc..57a59040d 100644 --- a/picard/extension_points/formats.py +++ b/picard/extension_points/formats.py @@ -30,10 +30,14 @@ from picard.plugin import ExtensionPoint ext_point_formats = ExtensionPoint(label='formats') -formats_extensions = {} +_formats_extensions = {} def register_format(file_format): ext_point_formats.register(file_format.__module__, file_format) for ext in file_format.EXTENSIONS: - formats_extensions[ext[1:]] = file_format + _formats_extensions[ext[1:]] = file_format + + +def ext_to_format(ext): + return _formats_extensions.get(ext, None) diff --git a/picard/extension_points/script_functions.py b/picard/extension_points/script_functions.py new file mode 100644 index 000000000..d6552ed7b --- /dev/null +++ b/picard/extension_points/script_functions.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2006-2009, 2012 Lukáš Lalinský +# Copyright (C) 2007 Javier Kohen +# Copyright (C) 2008-2011, 2014-2015, 2018-2021, 2023 Philipp Wolfer +# Copyright (C) 2009 Carlin Mangar +# Copyright (C) 2009 Nikolai Prokoschenko +# Copyright (C) 2011-2012 Michael Wiencek +# Copyright (C) 2012 Chad Wilson +# Copyright (C) 2012 stephen +# Copyright (C) 2012, 2014, 2017, 2021 Wieland Hoffmann +# Copyright (C) 2013-2014, 2017-2024 Laurent Monin +# Copyright (C) 2014, 2017, 2021 Sophist-UK +# Copyright (C) 2016-2017 Sambhav Kothari +# Copyright (C) 2016-2017 Ville Skyttä +# Copyright (C) 2017-2018 Antonio Larrosa +# Copyright (C) 2018 Calvin Walton +# Copyright (C) 2018 virusMac +# Copyright (C) 2020-2023 Bob Swift +# Copyright (C) 2021 Adam James +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from collections import namedtuple +from inspect import getfullargspec + + +try: + from markdown import markdown +except ImportError: + markdown = None + +from picard.i18n import gettext as _ +from picard.plugin import ExtensionPoint + + +ext_point_script_functions = ExtensionPoint(label='script_functions') + + +Bound = namedtuple('Bound', ['lower', 'upper']) + + +class FunctionRegistryItem: + def __init__(self, function, eval_args, argcount, documentation=None, + name=None, module=None): + self.function = function + self.eval_args = eval_args + self.argcount = argcount + self.documentation = documentation + self.name = name + self.module = module + + def __repr__(self): + return '{classname}({me.function}, {me.eval_args}, {me.argcount}, {doc})'.format( + classname=self.__class__.__name__, + me=self, + doc='"""{0}"""'.format(self.documentation) if self.documentation else None + ) + + def _postprocess(self, data, postprocessor): + if postprocessor is not None: + data = postprocessor(data, function=self) + return data + + def markdowndoc(self, postprocessor=None): + if self.documentation is not None: + ret = _(self.documentation) + else: + ret = '' + return self._postprocess(ret, postprocessor) + + def htmldoc(self, postprocessor=None): + if markdown is not None: + ret = markdown(self.markdowndoc()) + else: + ret = '' + return self._postprocess(ret, postprocessor) + + +def register_script_function(function, name=None, eval_args=True, + check_argcount=True, documentation=None): + """Registers a script function. If ``name`` is ``None``, + ``function.__name__`` will be used. + If ``eval_args`` is ``False``, the arguments will not be evaluated before being + passed to ``function``. + If ``check_argcount`` is ``False`` the number of arguments passed to the + function will not be verified.""" + + args, varargs, varkw, defaults, kwonlyargs, kwonlydefaults, annotations = getfullargspec(function) + + required_kwonlyargs = len(kwonlyargs) + if kwonlydefaults is not None: + required_kwonlyargs -= len(kwonlydefaults.keys()) + if required_kwonlyargs: + raise TypeError("Functions with required keyword-only parameters are not supported") + + args = len(args) - 1 # -1 for the parser + varargs = varargs is not None + defaults = len(defaults) if defaults else 0 + + argcount = Bound(args - defaults, args if not varargs else None) + + if name is None: + name = function.__name__ + ext_point_script_functions.register( + function.__module__, + ( + name, + FunctionRegistryItem( + function, + eval_args, + argcount if argcount and check_argcount else False, + documentation=documentation, + name=name, + module=function.__module__, + ) + ) + ) + + +def script_function(name=None, eval_args=True, check_argcount=True, prefix='func_', documentation=None): + """Decorator helper to register script functions + + It calls ``register_script_function()`` and share same arguments + Extra optional arguments: + ``prefix``: define the prefix to be removed from defined function to name script function + By default, ``func_foo`` will create ``foo`` script function + + Example: + @script_function(eval_args=False) + def func_myscriptfunc(): + ... + """ + def script_function_decorator(func): + fname = func.__name__ + if name is None and prefix and fname.startswith(prefix): + sname = fname[len(prefix):] + else: + sname = name + register_script_function( + func, + name=sname, + eval_args=eval_args, + check_argcount=check_argcount, + documentation=documentation + ) + return func + return script_function_decorator diff --git a/picard/formats/util.py b/picard/formats/util.py index e2cb9835f..a8716c140 100644 --- a/picard/formats/util.py +++ b/picard/formats/util.py @@ -29,7 +29,7 @@ from picard import log from picard.extension_points.formats import ( ext_point_formats, - formats_extensions, + ext_to_format, ) @@ -43,10 +43,6 @@ def supported_extensions(): return [ext for exts, name in supported_formats() for ext in exts] -def ext_to_format(ext): - return formats_extensions.get(ext, None) - - def guess_format(filename, options=None): """Select the best matching file type amongst supported formats.""" if options is None: @@ -79,7 +75,10 @@ def open_(filename): i = filename.rfind(".") if i >= 0: ext = filename[i+1:].lower() - audio_file = formats_extensions[ext](filename) + file_format = ext_to_format(ext) + if file_format is None: + return None + audio_file = file_format(filename) else: # If there is no extension, try to guess the format based on file headers audio_file = guess_format(filename) diff --git a/picard/script/__init__.py b/picard/script/__init__.py index e8c8bc45a..f056c5af0 100644 --- a/picard/script/__init__.py +++ b/picard/script/__init__.py @@ -41,14 +41,13 @@ from picard.const.defaults import ( DEFAULT_FILE_NAMING_FORMAT, DEFAULT_NAMING_PRESET_ID, ) +from picard.extension_points import script_functions from picard.i18n import ( N_, gettext as _, ) -from picard.script.functions import ( # noqa: F401 # pylint: disable=unused-import - register_script_function, - script_function, -) +# Those imports are required to actually parse the code and interpret decorators +import picard.script.functions # noqa: F401 # pylint: disable=unused-import from picard.script.parser import ( # noqa: F401 # pylint: disable=unused-import MultiValue, ScriptEndOfFile, @@ -73,7 +72,7 @@ class ScriptFunctionDocError(Exception): def script_function_documentation(name, fmt, functions=None, postprocessor=None): if functions is None: - functions = dict(ScriptParser._function_registry) + functions = dict(script_functions.ext_point_script_functions) if name not in functions: raise ScriptFunctionDocError("no such function: %s (known functions: %r)" % (name, [name for name in functions])) @@ -87,13 +86,13 @@ def script_function_documentation(name, fmt, functions=None, postprocessor=None) def script_function_names(functions=None): if functions is None: - functions = dict(ScriptParser._function_registry) + functions = dict(script_functions.ext_point_script_functions) yield from sorted(functions) def script_function_documentation_all(fmt='markdown', pre='', post='', postprocessor=None): - functions = dict(ScriptParser._function_registry) + functions = dict(script_functions.ext_point_script_functions) doc_elements = [] for name in script_function_names(functions): doc_element = script_function_documentation(name, fmt, diff --git a/picard/script/functions.py b/picard/script/functions.py index f656e8feb..db1b41b99 100644 --- a/picard/script/functions.py +++ b/picard/script/functions.py @@ -39,21 +39,19 @@ from collections import namedtuple import datetime from functools import reduce -from inspect import getfullargspec import operator import re import unicodedata from picard.const.countries import RELEASE_COUNTRIES +from picard.extension_points.script_functions import script_function from picard.i18n import ( N_, - gettext as _, gettext_countries, ) from picard.metadata import MULTI_VALUED_JOINER from picard.script.parser import ( MultiValue, - ScriptParser, ScriptRuntimeError, normalize_tagname, ) @@ -63,123 +61,6 @@ from picard.util import ( ) -try: - from markdown import markdown -except ImportError: - markdown = None - - -Bound = namedtuple('Bound', ['lower', 'upper']) - - -class FunctionRegistryItem: - def __init__(self, function, eval_args, argcount, documentation=None, - name=None, module=None): - self.function = function - self.eval_args = eval_args - self.argcount = argcount - self.documentation = documentation - self.name = name - self.module = module - - def __repr__(self): - return '{classname}({me.function}, {me.eval_args}, {me.argcount}, {doc})'.format( - classname=self.__class__.__name__, - me=self, - doc='"""{0}"""'.format(self.documentation) if self.documentation else None - ) - - def _postprocess(self, data, postprocessor): - if postprocessor is not None: - data = postprocessor(data, function=self) - return data - - def markdowndoc(self, postprocessor=None): - if self.documentation is not None: - ret = _(self.documentation) - else: - ret = '' - return self._postprocess(ret, postprocessor) - - def htmldoc(self, postprocessor=None): - if markdown is not None: - ret = markdown(self.markdowndoc()) - else: - ret = '' - return self._postprocess(ret, postprocessor) - - -def register_script_function(function, name=None, eval_args=True, - check_argcount=True, documentation=None): - """Registers a script function. If ``name`` is ``None``, - ``function.__name__`` will be used. - If ``eval_args`` is ``False``, the arguments will not be evaluated before being - passed to ``function``. - If ``check_argcount`` is ``False`` the number of arguments passed to the - function will not be verified.""" - - args, varargs, varkw, defaults, kwonlyargs, kwonlydefaults, annotations = getfullargspec(function) - - required_kwonlyargs = len(kwonlyargs) - if kwonlydefaults is not None: - required_kwonlyargs -= len(kwonlydefaults.keys()) - if required_kwonlyargs: - raise TypeError("Functions with required keyword-only parameters are not supported") - - args = len(args) - 1 # -1 for the parser - varargs = varargs is not None - defaults = len(defaults) if defaults else 0 - - argcount = Bound(args - defaults, args if not varargs else None) - - if name is None: - name = function.__name__ - ScriptParser._function_registry.register( - function.__module__, - ( - name, - FunctionRegistryItem( - function, - eval_args, - argcount if argcount and check_argcount else False, - documentation=documentation, - name=name, - module=function.__module__, - ) - ) - ) - - -def script_function(name=None, eval_args=True, check_argcount=True, prefix='func_', documentation=None): - """Decorator helper to register script functions - - It calls ``register_script_function()`` and share same arguments - Extra optional arguments: - ``prefix``: define the prefix to be removed from defined function to name script function - By default, ``func_foo`` will create ``foo`` script function - - Example: - @script_function(eval_args=False) - def func_myscriptfunc(): - ... - """ - def script_function_decorator(func): - fname = func.__name__ - if name is None and prefix and fname.startswith(prefix): - sname = fname[len(prefix):] - else: - sname = name - register_script_function( - func, - name=sname, - eval_args=eval_args, - check_argcount=check_argcount, - documentation=documentation - ) - return func - return script_function_decorator - - def _compute_int(operation, *args): return str(reduce(operation, map(int, args))) diff --git a/picard/script/parser.py b/picard/script/parser.py index 9f7777e34..bd90300f6 100644 --- a/picard/script/parser.py +++ b/picard/script/parser.py @@ -38,11 +38,11 @@ from collections.abc import MutableSequence from queue import LifoQueue +from picard.extension_points import script_functions from picard.metadata import ( MULTI_VALUED_JOINER, Metadata, ) -from picard.plugin import ExtensionPoint class ScriptError(Exception): @@ -216,7 +216,6 @@ Grammar: argument ::= (variable | function | argtext)* """ - _function_registry = ExtensionPoint(label='function_registry') _cache = {} def __init__(self): @@ -362,9 +361,7 @@ Grammar: return (tokens, ch) def load_functions(self): - self.functions = {} - for name, item in ScriptParser._function_registry: - self.functions[name] = item + self.functions = dict(script_functions.ext_point_script_functions) def parse(self, script, functions=False): """Parse the script.""" diff --git a/test/test_script.py b/test/test_script.py index b532271b1..a5a6261d7 100644 --- a/test/test_script.py +++ b/test/test_script.py @@ -43,6 +43,11 @@ from test.picardtestcase import PicardTestCase from picard.cluster import Cluster from picard.const.defaults import DEFAULT_FILE_NAMING_FORMAT +from picard.extension_points.script_functions import ( + FunctionRegistryItem, + register_script_function, + script_function, +) from picard.metadata import ( MULTI_VALUED_JOINER, Metadata, @@ -60,12 +65,9 @@ from picard.script import ( ScriptSyntaxError, ScriptUnicodeError, ScriptUnknownFunction, - register_script_function, - script_function, script_function_documentation, script_function_documentation_all, ) -from picard.script.functions import FunctionRegistryItem try: @@ -170,77 +172,77 @@ class ScriptParserTest(PicardTestCase): with self.assertRaisesRegex(ScriptUnicodeError, areg): self.parser.eval("\\ufffg") + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_decorator_default(self): # test default decorator and default prefix - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): - @script_function() - def func_somefunc(parser): - return "x" - self.assertScriptResultEquals("$somefunc()", "x") + @script_function() + def func_somefunc(parser): + return "x" + self.assertScriptResultEquals("$somefunc()", "x") + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_decorator_no_prefix(self): # function without prefix - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): - @script_function() - def somefunc(parser): - return "x" - self.assertScriptResultEquals("$somefunc()", "x") + @script_function() + def somefunc(parser): + return "x" + self.assertScriptResultEquals("$somefunc()", "x") + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_decorator_arg(self): # function with argument - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): - @script_function() - def somefunc(parser, arg): - return arg + @script_function() + def somefunc(parser, arg): + return arg - @script_function() - def title(parser, arg): - return arg.upper() + @script_function() + def title(parser, arg): + return arg.upper() - self.assertScriptResultEquals("$somefunc($title(x))", "X") - areg = r"^\d+:\d+:\$somefunc: Wrong number of arguments for \$somefunc: Expected exactly 1" - with self.assertRaisesRegex(ScriptError, areg): - self.parser.eval("$somefunc()") + self.assertScriptResultEquals("$somefunc($title(x))", "X") + areg = r"^\d+:\d+:\$somefunc: Wrong number of arguments for \$somefunc: Expected exactly 1" + with self.assertRaisesRegex(ScriptError, areg): + self.parser.eval("$somefunc()") + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_decorator_argcount(self): # ignore argument count - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): - @script_function(check_argcount=False) - def somefunc(parser, *arg): - return str(len(arg)) - self.assertScriptResultEquals("$somefunc(a,b,c)", "3") + @script_function(check_argcount=False) + def somefunc(parser, *arg): + return str(len(arg)) + self.assertScriptResultEquals("$somefunc(a,b,c)", "3") + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_decorator_altname(self): # alternative name - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): - @script_function(name="otherfunc") - def somefunc4(parser): - return "x" - self.assertScriptResultEquals("$otherfunc()", "x") - areg = r"^\d+:\d+:\$somefunc: Unknown function '\$somefunc'" - with self.assertRaisesRegex(ScriptError, areg): - self.parser.eval("$somefunc()") + @script_function(name="otherfunc") + def somefunc4(parser): + return "x" + self.assertScriptResultEquals("$otherfunc()", "x") + areg = r"^\d+:\d+:\$somefunc: Unknown function '\$somefunc'" + with self.assertRaisesRegex(ScriptError, areg): + self.parser.eval("$somefunc()") + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_decorator_altprefix(self): # alternative prefix - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): - @script_function(prefix='theprefix_') - def theprefix_somefunc(parser): - return "x" - self.assertScriptResultEquals("$somefunc()", "x") + @script_function(prefix='theprefix_') + def theprefix_somefunc(parser): + return "x" + self.assertScriptResultEquals("$somefunc()", "x") + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_decorator_eval_args(self): # disable argument evaluation - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): - @script_function(eval_args=False) - def somefunc(parser, arg): - return arg.eval(parser) + @script_function(eval_args=False) + def somefunc(parser, arg): + return arg.eval(parser) - @script_function() - def title(parser, arg): - return arg.upper() + @script_function() + def title(parser, arg): + return arg.upper() - self.assertScriptResultEquals("$somefunc($title(x))", "X") + self.assertScriptResultEquals("$somefunc($title(x))", "X") @staticmethod def assertStartswith(text, expect): @@ -252,98 +254,95 @@ class ScriptParserTest(PicardTestCase): if not text.endswith(expect): raise AssertionError("do not end with %r but with %r" % (expect, text[-len(expect):])) + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_documentation_nodoc(self): """test script_function_documentation() with a function without documentation""" - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): + @script_function() + def func_nodocfunc(parser): + return "" - @script_function() - def func_nodocfunc(parser): - return "" - - doc = script_function_documentation('nodocfunc', 'markdown') - self.assertEqual(doc, '') - doc = script_function_documentation('nodocfunc', 'html') - self.assertEqual(doc, '') + doc = script_function_documentation('nodocfunc', 'markdown') + self.assertEqual(doc, '') + doc = script_function_documentation('nodocfunc', 'html') + self.assertEqual(doc, '') + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_documentation(self): """test script_function_documentation() with a function with documentation""" - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): - # the documentation used to test includes backquotes - testdoc = '`$somefunc()`' + # the documentation used to test includes backquotes + testdoc = '`$somefunc()`' - @script_function(documentation=testdoc) - def func_somefunc(parser): - return "x" + @script_function(documentation=testdoc) + def func_somefunc(parser): + return "x" - doc = script_function_documentation('somefunc', 'markdown') - self.assertEqual(doc, testdoc) - areg = r"^no such documentation format: unknownformat" - with self.assertRaisesRegex(ScriptFunctionDocError, areg): - script_function_documentation('somefunc', 'unknownformat') + doc = script_function_documentation('somefunc', 'markdown') + self.assertEqual(doc, testdoc) + areg = r"^no such documentation format: unknownformat" + with self.assertRaisesRegex(ScriptFunctionDocError, areg): + script_function_documentation('somefunc', 'unknownformat') @unittest.skipUnless(markdown, "markdown module missing") + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_documentation_html(self): """test script_function_documentation() with a function with documentation""" - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): - # get html code as generated by markdown - pre, post = markdown('`XXX`').split('XXX') + # get html code as generated by markdown + pre, post = markdown('`XXX`').split('XXX') - # the documentation used to test includes backquotes - testdoc = '`$somefunc()`' + # the documentation used to test includes backquotes + testdoc = '`$somefunc()`' - @script_function(documentation=testdoc) - def func_somefunc(parser): - return "x" + @script_function(documentation=testdoc) + def func_somefunc(parser): + return "x" - doc = script_function_documentation('somefunc', 'html') - self.assertEqual(doc, pre + '$somefunc()' + post) + doc = script_function_documentation('somefunc', 'html') + self.assertEqual(doc, pre + '$somefunc()' + post) + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_documentation_unknown_function(self): """test script_function_documentation() with an unknown function""" - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): - areg = r"^no such function: unknownfunc" - with self.assertRaisesRegex(ScriptFunctionDocError, areg): - script_function_documentation('unknownfunc', 'html') + areg = r"^no such function: unknownfunc" + with self.assertRaisesRegex(ScriptFunctionDocError, areg): + script_function_documentation('unknownfunc', 'html') + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_documentation_all(self): """test script_function_documentation_all() with markdown format""" - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): + @script_function(documentation='somedoc2') + def func_somefunc2(parser): + return "x" - @script_function(documentation='somedoc2') - def func_somefunc2(parser): - return "x" + @script_function(documentation='somedoc1') + def func_somefunc1(parser): + return "x" - @script_function(documentation='somedoc1') - def func_somefunc1(parser): - return "x" - - docall = script_function_documentation_all() - self.assertEqual(docall, 'somedoc1\nsomedoc2') + docall = script_function_documentation_all() + self.assertEqual(docall, 'somedoc1\nsomedoc2') @unittest.skipUnless(markdown, "markdown module missing") + @patch('picard.extension_points.script_functions.ext_point_script_functions', ExtensionPoint(label='test_script')) def test_script_function_documentation_all_html(self): """test script_function_documentation_all() with html format""" - with patch.object(ScriptParser, '_function_registry', ExtensionPoint()): + # get html code as generated by markdown + pre, post = markdown('XXX').split('XXX') - # get html code as generated by markdown - pre, post = markdown('XXX').split('XXX') + @script_function(documentation='somedoc') + def func_somefunc(parser): + return "x" - @script_function(documentation='somedoc') - def func_somefunc(parser): - return "x" + def postprocessor(data, function): + return 'w' + data + function.name + 'y' - def postprocessor(data, function): - return 'w' + data + function.name + 'y' + docall = script_function_documentation_all( + fmt='html', + pre='