diff --git a/picard/script/__init__.py b/picard/script/__init__.py index 9dbbbaa76..1e8cbf1a5 100644 --- a/picard/script/__init__.py +++ b/picard/script/__init__.py @@ -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. diff --git a/picard/script/serializer.py b/picard/script/serializer.py new file mode 100644 index 000000000..ef6530a86 --- /dev/null +++ b/picard/script/serializer.py @@ -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) diff --git a/picard/ui/scripteditor.py b/picard/ui/scripteditor.py index 8709c1eb2..94d989523 100644 --- a/picard/ui/scripteditor.py +++ b/picard/ui/scripteditor.py @@ -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, diff --git a/test/test_scriptclasses.py b/test/test_script_serializer.py similarity index 93% rename from test/test_scriptclasses.py rename to test/test_script_serializer.py index 28fdda427..2999aeeca 100644 --- a/test/test_scriptclasses.py +++ b/test/test_script_serializer.py @@ -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(), '{'