PICARD-2213: Move classes for script serialization into submodule

This commit is contained in:
Philipp Wolfer
2021-05-18 17:44:15 +02:00
parent c9dabff15d
commit ff3e6f80f2
4 changed files with 335 additions and 296 deletions

View File

@@ -35,23 +35,8 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
from copy import deepcopy
import datetime
from enum import (
IntEnum,
unique,
)
import json
import uuid
import yaml
from picard.config import get_config
from picard.const import (
DEFAULT_FILE_NAMING_FORMAT,
DEFAULT_SCRIPT_NAME,
SCRIPT_LANGUAGE_VERSION,
)
from picard.const import DEFAULT_FILE_NAMING_FORMAT
from picard.script.functions import ( # noqa: F401 # pylint: disable=unused-import
register_script_function,
script_function,
@@ -71,6 +56,7 @@ from picard.script.parser import ( # noqa: F401 # pylint: disable=unused-import
ScriptUnknownFunction,
ScriptVariable,
)
from picard.script.serializer import FileNamingScript
class ScriptFunctionDocError(Exception):
@@ -119,282 +105,6 @@ def enabled_tagger_scripts_texts():
return [(s_name, s_text) for _s_pos, s_name, s_enabled, s_text in config.setting["list_of_scripts"] if s_enabled and s_text]
@unique
class PicardScriptType(IntEnum):
"""Picard Script object types
"""
BASE = 0
TAGGER = 1
FILENAMING = 2
class ScriptImportError(Exception):
def __init__(self, *args):
super().__init__(*args)
class MultilineLiteral(str):
@staticmethod
def yaml_presenter(dumper, data):
if data:
data = data.rstrip() + '\n'
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
yaml.add_representer(MultilineLiteral, MultilineLiteral.yaml_presenter)
class PicardScript():
"""Base class for Picard script objects.
"""
# Base class developed to support future tagging script class as possible replacement for currently used tuples in config.setting["list_of_scripts"].
TYPE = PicardScriptType.BASE
OUTPUT_FIELDS = ('title', 'script_language_version', 'script', 'id')
# Don't automatically trigger changing the `script_last_updated` property when updating these properties.
_last_updated_ignore_list = {'last_updated', 'readonly', 'deletable', 'id'}
def __init__(self, script='', title='', id=None, last_updated=None, script_language_version=None):
"""Base class for Picard script objects
Args:
script (str): Text of the script.
title (str): Title of the script.
id (str): ID code for the script. Defaults to a system generated uuid.
last_updated (str): The UTC date and time when the script was last updated. Defaults to current date/time.
"""
self.title = title if title else DEFAULT_SCRIPT_NAME
self.script = script
if not id:
self._set_new_id()
else:
self.id = id
if last_updated is None:
self.update_last_updated()
else:
self.last_updated = last_updated
if script_language_version is None or not script_language_version:
self.script_language_version = SCRIPT_LANGUAGE_VERSION
else:
self.script_language_version = script_language_version
def _set_new_id(self):
"""Sets the ID of the script to a new system generated uuid.
"""
self.id = str(uuid.uuid4())
def __getitem__(self, setting):
"""A safe way of getting the value of the specified property setting, because
it handles missing properties by returning None rather than raising an exception.
Args:
setting (str): The setting whose value to return.
Returns:
any: The value of the specified setting, or None if the setting was not found.
"""
if hasattr(self, setting):
value = getattr(self, setting)
if isinstance(value, str):
value = value.strip()
return value
return None
@property
def script(self):
return self._script
@script.setter
def script(self, value):
self._script = MultilineLiteral(value)
@staticmethod
def make_last_updated():
"""Provide consistently formatted last updated string.
Returns:
str: Last updated string from current date and time
"""
return datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
def update_last_updated(self):
"""Update the last updated attribute to the current UTC date and time.
"""
self.last_updated = self.make_last_updated()
def update_script_setting(self, **kwargs):
"""Updates the value of the specified properties.
Args:
**kwargs: Properties to update in the form `property=value`.
"""
self._update_from_dict(kwargs)
def _update_from_dict(self, settings):
"""Updates the values of the properties based on the contents of the dictionary provided.
Properties in the `settings` dictionary that are not found in the script object are ignored.
Args:
settings (dict): The settings to update.
"""
updated = False
for key, value in settings.items():
if value is not None and hasattr(self, key):
if isinstance(value, str):
value = value.strip()
setattr(self, key, value)
if key not in self._last_updated_ignore_list:
updated = True
# Don't overwrite `last_updated` with a generated value if a last_updated value has been provided.
if updated and 'last_updated' not in settings:
self.update_last_updated()
def copy(self):
"""Create a copy of the current script object with updated title and last updated attributes.
"""
new_object = deepcopy(self)
new_object.update_script_setting(
title=_("%s (Copy)") % self.title,
script_language_version=SCRIPT_LANGUAGE_VERSION,
readonly=False,
deletable=True
)
new_object._set_new_id()
return new_object
def to_yaml(self):
"""Converts the properties of the script object to a YAML formatted string. Note that only property
names listed in `OUTPUT_FIELDS` will be included in the output.
Returns:
str: The properties of the script object formatted as a YAML string.
"""
items = {key: getattr(self, key) for key in self.OUTPUT_FIELDS}
return yaml.dump(items, sort_keys=False)
def to_json(self, indent=None):
"""Converts the properties of the script object to a JSON formatted string. Note that only property
names listed in `OUTPUT_FIELDS` will be included in the output.
Args:
indent (int): Amount to indent the output. Defaults to None.
Returns:
str: The properties of the script object formatted as a JSON string.
"""
items = {key: getattr(self, key) for key in self.OUTPUT_FIELDS}
return json.dumps(items, indent=indent, sort_keys=True)
@classmethod
def create_from_yaml(cls, yaml_string, create_new_id=True):
"""Creates an instance based on the contents of the YAML string provided.
Properties in the YAML string that are not found in the script object are ignored.
Args:
yaml_string (str): YAML string containing the property settings.
Returns:
object: An instance of the class, populated from the property settings in the YAML string.
"""
new_object = cls()
yaml_dict = yaml.safe_load(yaml_string)
if not isinstance(yaml_dict, dict):
raise ScriptImportError(N_("File content not a dictionary"))
if 'title' not in yaml_dict or 'script' not in yaml_dict:
raise ScriptImportError(N_('Invalid script package'))
new_object._update_from_dict(yaml_dict)
if create_new_id or not new_object['id']:
new_object._set_new_id()
return new_object
@classmethod
def create_from_json(cls, json_string, create_new_id=True):
"""Creates an instance based on the contents of the JSON string provided.
Properties in the JSON string that are not found in the script object are ignored.
Args:
json_string (str): JSON string containing the property settings.
create_new_id (bool): Do not use the existing id. Defaults to True.
Returns:
object: An instance of the class, populated from the property settings in the JSON string.
"""
new_object = cls()
new_object.title = ''
new_object.update_from_json(json_string)
if not (new_object['title'] and new_object['script']):
raise ScriptImportError(N_('Invalid script package'))
if create_new_id or not new_object['id']:
new_object._set_new_id()
return new_object
def update_from_json(self, json_string):
"""Updates the values of the properties based on the contents of the JSON string provided.
Properties in the JSON string that are not found in the script object are ignored.
Args:
json_string (str): JSON string containing the property settings.
"""
try:
decoded_string = json.loads(json_string)
except json.decoder.JSONDecodeError:
raise ScriptImportError(N_("Unable to decode JSON string"))
self._update_from_dict(decoded_string)
class FileNamingScript(PicardScript):
"""Picard file naming script class
"""
TYPE = PicardScriptType.FILENAMING
OUTPUT_FIELDS = ('title', 'description', 'author', 'license', 'version', 'last_updated', 'script_language_version', 'script', 'id')
def __init__(
self,
script='',
title='',
id=None,
readonly=False,
deletable=True,
author='',
description='',
license='',
version='',
last_updated=None,
script_language_version=None
):
"""Creates a Picard file naming script object.
Args:
script (str): Text of the script.
title (str): Title of the script.
id (str): ID code for the script. Defaults to a system generated uuid.
readonly (bool): Identifies if the script is readonly. Defaults to False.
deletable (bool): Identifies if the script can be deleted from the selection list. Defaults to True.
author (str): The author of the script. Defaults to ''.
description (str): A description of the script, typically including type of output and any required plugins or settings. Defaults to ''.
license (str): The license under which the script is being distributed. Defaults to ''.
version (str): Identifies the version of the script. Defaults to ''.
last_updated (str): The UTC date and time when the script was last updated. Defaults to current date/time.
script_language_version (str): The version of the script language supported by the script.
"""
super().__init__(script=script, title=title, id=id, last_updated=last_updated, script_language_version=script_language_version)
self.readonly = readonly # for presets
self.deletable = deletable # Allow removal from list of scripts
self.author = author
self.description = description
self.license = license
self.version = version
@property
def description(self):
return self._description
@description.setter
def description(self, value):
self._description = MultilineLiteral(value)
def get_file_naming_script_presets():
"""Generator of preset example file naming script objects.

312
picard/script/serializer.py Normal file
View File

@@ -0,0 +1,312 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2021 Bob Swift
# Copyright (C) 2021 Philipp Wolfer
#
# 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 copy import deepcopy
import datetime
from enum import (
IntEnum,
unique,
)
import json
import uuid
import yaml
from picard.const import (
DEFAULT_SCRIPT_NAME,
SCRIPT_LANGUAGE_VERSION,
)
@unique
class PicardScriptType(IntEnum):
"""Picard Script object types
"""
BASE = 0
TAGGER = 1
FILENAMING = 2
class ScriptImportError(Exception):
def __init__(self, *args):
super().__init__(*args)
class MultilineLiteral(str):
@staticmethod
def yaml_presenter(dumper, data):
if data:
data = data.rstrip() + '\n'
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
yaml.add_representer(MultilineLiteral, MultilineLiteral.yaml_presenter)
class PicardScript():
"""Base class for Picard script objects.
"""
# Base class developed to support future tagging script class as possible replacement for currently used tuples in config.setting["list_of_scripts"].
TYPE = PicardScriptType.BASE
OUTPUT_FIELDS = ('title', 'script_language_version', 'script', 'id')
# Don't automatically trigger changing the `script_last_updated` property when updating these properties.
_last_updated_ignore_list = {'last_updated', 'readonly', 'deletable', 'id'}
def __init__(self, script='', title='', id=None, last_updated=None, script_language_version=None):
"""Base class for Picard script objects
Args:
script (str): Text of the script.
title (str): Title of the script.
id (str): ID code for the script. Defaults to a system generated uuid.
last_updated (str): The UTC date and time when the script was last updated. Defaults to current date/time.
"""
self.title = title if title else DEFAULT_SCRIPT_NAME
self.script = script
if not id:
self._set_new_id()
else:
self.id = id
if last_updated is None:
self.update_last_updated()
else:
self.last_updated = last_updated
if script_language_version is None or not script_language_version:
self.script_language_version = SCRIPT_LANGUAGE_VERSION
else:
self.script_language_version = script_language_version
def _set_new_id(self):
"""Sets the ID of the script to a new system generated uuid.
"""
self.id = str(uuid.uuid4())
def __getitem__(self, setting):
"""A safe way of getting the value of the specified property setting, because
it handles missing properties by returning None rather than raising an exception.
Args:
setting (str): The setting whose value to return.
Returns:
any: The value of the specified setting, or None if the setting was not found.
"""
if hasattr(self, setting):
value = getattr(self, setting)
if isinstance(value, str):
value = value.strip()
return value
return None
@property
def script(self):
return self._script
@script.setter
def script(self, value):
self._script = MultilineLiteral(value)
@staticmethod
def make_last_updated():
"""Provide consistently formatted last updated string.
Returns:
str: Last updated string from current date and time
"""
return datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
def update_last_updated(self):
"""Update the last updated attribute to the current UTC date and time.
"""
self.last_updated = self.make_last_updated()
def update_script_setting(self, **kwargs):
"""Updates the value of the specified properties.
Args:
**kwargs: Properties to update in the form `property=value`.
"""
self._update_from_dict(kwargs)
def _update_from_dict(self, settings):
"""Updates the values of the properties based on the contents of the dictionary provided.
Properties in the `settings` dictionary that are not found in the script object are ignored.
Args:
settings (dict): The settings to update.
"""
updated = False
for key, value in settings.items():
if value is not None and hasattr(self, key):
if isinstance(value, str):
value = value.strip()
setattr(self, key, value)
if key not in self._last_updated_ignore_list:
updated = True
# Don't overwrite `last_updated` with a generated value if a last_updated value has been provided.
if updated and 'last_updated' not in settings:
self.update_last_updated()
def copy(self):
"""Create a copy of the current script object with updated title and last updated attributes.
"""
new_object = deepcopy(self)
new_object.update_script_setting(
title=_("%s (Copy)") % self.title,
script_language_version=SCRIPT_LANGUAGE_VERSION,
readonly=False,
deletable=True
)
new_object._set_new_id()
return new_object
def to_yaml(self):
"""Converts the properties of the script object to a YAML formatted string. Note that only property
names listed in `OUTPUT_FIELDS` will be included in the output.
Returns:
str: The properties of the script object formatted as a YAML string.
"""
items = {key: getattr(self, key) for key in self.OUTPUT_FIELDS}
return yaml.dump(items, sort_keys=False)
def to_json(self, indent=None):
"""Converts the properties of the script object to a JSON formatted string. Note that only property
names listed in `OUTPUT_FIELDS` will be included in the output.
Args:
indent (int): Amount to indent the output. Defaults to None.
Returns:
str: The properties of the script object formatted as a JSON string.
"""
items = {key: getattr(self, key) for key in self.OUTPUT_FIELDS}
return json.dumps(items, indent=indent, sort_keys=True)
@classmethod
def create_from_yaml(cls, yaml_string, create_new_id=True):
"""Creates an instance based on the contents of the YAML string provided.
Properties in the YAML string that are not found in the script object are ignored.
Args:
yaml_string (str): YAML string containing the property settings.
Returns:
object: An instance of the class, populated from the property settings in the YAML string.
"""
new_object = cls()
yaml_dict = yaml.safe_load(yaml_string)
if not isinstance(yaml_dict, dict):
raise ScriptImportError(N_("File content not a dictionary"))
if 'title' not in yaml_dict or 'script' not in yaml_dict:
raise ScriptImportError(N_('Invalid script package'))
new_object._update_from_dict(yaml_dict)
if create_new_id or not new_object['id']:
new_object._set_new_id()
return new_object
@classmethod
def create_from_json(cls, json_string, create_new_id=True):
"""Creates an instance based on the contents of the JSON string provided.
Properties in the JSON string that are not found in the script object are ignored.
Args:
json_string (str): JSON string containing the property settings.
create_new_id (bool): Do not use the existing id. Defaults to True.
Returns:
object: An instance of the class, populated from the property settings in the JSON string.
"""
new_object = cls()
new_object.title = ''
new_object.update_from_json(json_string)
if not (new_object['title'] and new_object['script']):
raise ScriptImportError(N_('Invalid script package'))
if create_new_id or not new_object['id']:
new_object._set_new_id()
return new_object
def update_from_json(self, json_string):
"""Updates the values of the properties based on the contents of the JSON string provided.
Properties in the JSON string that are not found in the script object are ignored.
Args:
json_string (str): JSON string containing the property settings.
"""
try:
decoded_string = json.loads(json_string)
except json.decoder.JSONDecodeError:
raise ScriptImportError(N_("Unable to decode JSON string"))
self._update_from_dict(decoded_string)
class FileNamingScript(PicardScript):
"""Picard file naming script class
"""
TYPE = PicardScriptType.FILENAMING
OUTPUT_FIELDS = ('title', 'description', 'author', 'license', 'version', 'last_updated', 'script_language_version', 'script', 'id')
def __init__(
self,
script='',
title='',
id=None,
readonly=False,
deletable=True,
author='',
description='',
license='',
version='',
last_updated=None,
script_language_version=None
):
"""Creates a Picard file naming script object.
Args:
script (str): Text of the script.
title (str): Title of the script.
id (str): ID code for the script. Defaults to a system generated uuid.
readonly (bool): Identifies if the script is readonly. Defaults to False.
deletable (bool): Identifies if the script can be deleted from the selection list. Defaults to True.
author (str): The author of the script. Defaults to ''.
description (str): A description of the script, typically including type of output and any required plugins or settings. Defaults to ''.
license (str): The license under which the script is being distributed. Defaults to ''.
version (str): Identifies the version of the script. Defaults to ''.
last_updated (str): The UTC date and time when the script was last updated. Defaults to current date/time.
script_language_version (str): The version of the script language supported by the script.
"""
super().__init__(script=script, title=title, id=id, last_updated=last_updated, script_language_version=script_language_version)
self.readonly = readonly # for presets
self.deletable = deletable # Allow removal from list of scripts
self.author = author
self.description = description
self.license = license
self.version = version
@property
def description(self):
return self._description
@description.setter
def description(self, value):
self._description = MultilineLiteral(value)

View File

@@ -41,12 +41,14 @@ from picard.config import (
from picard.const import DEFAULT_FILE_NAMING_FORMAT
from picard.file import File
from picard.script import (
FileNamingScript,
ScriptError,
ScriptImportError,
ScriptParser,
get_file_naming_script_presets,
)
from picard.script.serializer import (
FileNamingScript,
ScriptImportError,
)
from picard.util import (
icontheme,
webbrowser2,

View File

@@ -3,6 +3,7 @@
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2021 Bob Swift
# Copyright (C) 2021 Philipp Wolfer
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
@@ -23,7 +24,7 @@ import datetime
from test.picardtestcase import PicardTestCase
from picard.script import (
from picard.script.serializer import (
FileNamingScript,
PicardScript,
ScriptImportError,
@@ -36,7 +37,7 @@ class _DateTime(datetime.datetime):
return cls(year=2020, month=1, day=2, hour=12, minute=34, second=56, microsecond=789, tzinfo=None)
class ScriptClassesTest(PicardTestCase):
class PicardScriptTest(PicardTestCase):
original_datetime = datetime.datetime
@@ -145,6 +146,20 @@ class ScriptClassesTest(PicardTestCase):
self.assertEqual(test_script['script'], 'Script text')
self.assertEqual(test_script.author, 'Script author')
self.assertEqual(test_script['author'], 'Script author')
self.assertEqual(
test_script.to_yaml(),
"title: Script 1\n"
"description: |\n"
" Script description\n"
"author: Script author\n"
"license: ''\n"
"version: ''\n"
"last_updated: '2021-04-26'\n"
"script_language_version: '1.0'\n"
"script: |\n"
" Script text\n"
"id: '12345'\n"
)
self.assertEqual(
test_script.to_json(),
'{'