Move script functions extension point to extension_points

This commit is contained in:
Laurent Monin
2024-05-09 19:47:50 +02:00
parent 52c01f36d3
commit cbfda11da6
8 changed files with 293 additions and 252 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)))

View File

@@ -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."""

View File

@@ -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='<div id="test">',
post="</div>\n",
postprocessor=postprocessor,
)
docall = script_function_documentation_all(
fmt='html',
pre='<div id="test">',
post="</div>\n",
postprocessor=postprocessor,
)
self.assertStartswith(docall, '<div id="test">w' + pre)
self.assertEndswith(docall, post + 'somefuncy</div>\n')
self.assertStartswith(docall, '<div id="test">w' + pre)
self.assertEndswith(docall, post + 'somefuncy</div>\n')
def test_unknown_function(self):
areg = r"^\d+:\d+:\$unknownfunction: Unknown function '\$unknownfunction'"

View File

@@ -26,9 +26,9 @@ from test.picardtestcase import PicardTestCase
from picard import config
from picard.const.sys import IS_WIN
from picard.extension_points.script_functions import register_script_function
from picard.file import File
from picard.metadata import Metadata
from picard.script import register_script_function
from picard.util.scripttofilename import (
script_to_filename,
script_to_filename_with_metadata,