From 6bb9e9638478ac01fb105ed761ae2b4cd5c4337b Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Mon, 11 Dec 2023 17:47:18 -0800 Subject: [PATCH] playlist_max maybe working? need to eval --- .../entries/script/custom_functions.py | 132 +++++++++++++++ .../entries/script/function_scripts.py | 158 ++++-------------- .../entries/script/variable_scripts.py | 127 +++++++------- .../script/functions/array_functions.py | 15 +- .../script/functions/conditional_functions.py | 12 ++ .../script/functions/map_functions.py | 41 +++-- src/ytdl_sub/script/types/function.py | 3 + src/ytdl_sub/script/utils/type_checking.py | 3 +- src/ytdl_sub/utils/scriptable.py | 5 +- tests/conftest.py | 2 +- tests/unit/script/types/test_array.py | 20 +-- tests/unit/script/types/test_function.py | 2 +- tests/unit/script/types/test_map.py | 32 ++-- 13 files changed, 322 insertions(+), 230 deletions(-) create mode 100644 src/ytdl_sub/entries/script/custom_functions.py diff --git a/src/ytdl_sub/entries/script/custom_functions.py b/src/ytdl_sub/entries/script/custom_functions.py new file mode 100644 index 00000000..260c4e06 --- /dev/null +++ b/src/ytdl_sub/entries/script/custom_functions.py @@ -0,0 +1,132 @@ +import os +import posixpath + +from yt_dlp.utils import sanitize_filename + +from ytdl_sub.script.functions import Functions +from ytdl_sub.script.types.map import Map +from ytdl_sub.script.types.resolvable import AnyArgument +from ytdl_sub.script.types.resolvable import Integer +from ytdl_sub.script.types.resolvable import ReturnableArgument +from ytdl_sub.script.types.resolvable import String +from ytdl_sub.script.utils.exceptions import RuntimeException +from ytdl_sub.utils.file_path import FilePathTruncater + + +def _pad(num: int, width: int): + return str(num).zfill(width) + + +_days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + + +class CustomFunctions: + @staticmethod + def legacy_bracket_safety(value: ReturnableArgument) -> ReturnableArgument: + if isinstance(value, String): + value = String(value.value.replace("{", "{").replace("}", "}")) + return value + + @staticmethod + def to_native_filepath(filepath: String) -> String: + return String(filepath.value.replace(posixpath.sep, os.sep)) + + @staticmethod + def truncate_filepath_if_too_long(filepath: String) -> String: + return String(FilePathTruncater.maybe_truncate_file_path(filepath.value)) + + @staticmethod + def sanitize(value: AnyArgument) -> String: + return String(sanitize_filename(str(value))) + + @staticmethod + def sanitize_plex_episode(string: String) -> String: + sanitized_string = CustomFunctions.sanitize(string).value + out = "" + for char in sanitized_string: + match char: + case "0": + out += "0" + case "1": + out += "1" + case "2": + out += "2" + case "3": + out += "3" + case "4": + out += "4" + case "5": + out += "5" + case "6": + out += "6" + case "7": + out += "7" + case "8": + out += "8" + case "9": + out += "9" + case _: + out += char + return String(out) + + @staticmethod + def to_date_metadata(yyyymmdd: String) -> Map: + date_str = yyyymmdd.value + if not (date_str.isnumeric() and len(date_str) == 8): + raise RuntimeException( + f"Expected input of date_metadata to be YYYYMMDD, but received {date_str}" + ) + + year: int = int(date_str[:4]) + month_padded: str = date_str[4:6] + day_padded: str = date_str[6:8] + + month: int = int(month_padded) + day: int = int(day_padded) + year_truncated: int = int(str(year)[-2:]) + + day_of_year: int = sum(_days_in_month[:month]) + day + total_days_in_month: int = _days_in_month[month] + total_days_in_year: int = 365 + if year % 4 == 0: + total_days_in_year += 1 + if month == 2: + total_days_in_month += 1 + if month > 2: + day_of_year += 1 + + day_of_year_reversed: int = total_days_in_year + 1 - day_of_year + month_reversed: int = 13 - month + day_reversed: int = total_days_in_month + 1 - day + + return Map( + { + String("date"): yyyymmdd, + String("date_standardized"): String(f"{year}-{month_padded}-{day_padded}"), + String("year"): Integer(year), + String("month"): Integer(month), + String("day"): Integer(day), + String("year_truncated"): String(year_truncated), + String("month_padded"): String(month_padded), + String("day_padded"): String(day_padded), + String("year_truncated_reversed"): Integer(100 - year_truncated), + String("month_reversed"): Integer(month_reversed), + String("month_reversed_padded"): String(_pad(month_reversed, width=2)), + String("day_reversed"): Integer(day_reversed), + String("day_reversed_padded"): String(_pad(day_reversed, width=2)), + String("day_of_year"): Integer(day_of_year), + String("day_of_year_padded"): String(_pad(day_of_year, width=3)), + String("day_of_year_reversed"): Integer(day_of_year_reversed), + String("day_of_year_reversed_padded"): String(_pad(day_of_year_reversed, width=3)), + } + ) + + @staticmethod + def register(): + if not Functions.is_built_in("sanitize"): + Functions.register_function(CustomFunctions.legacy_bracket_safety) + Functions.register_function(CustomFunctions.truncate_filepath_if_too_long) + Functions.register_function(CustomFunctions.to_native_filepath) + Functions.register_function(CustomFunctions.sanitize) + Functions.register_function(CustomFunctions.sanitize_plex_episode) + Functions.register_function(CustomFunctions.to_date_metadata) diff --git a/src/ytdl_sub/entries/script/function_scripts.py b/src/ytdl_sub/entries/script/function_scripts.py index 260c4e06..51d63344 100644 --- a/src/ytdl_sub/entries/script/function_scripts.py +++ b/src/ytdl_sub/entries/script/function_scripts.py @@ -1,132 +1,32 @@ -import os -import posixpath +from typing import Dict -from yt_dlp.utils import sanitize_filename +from ytdl_sub.entries.script.variable_definitions import VARIABLES as v -from ytdl_sub.script.functions import Functions -from ytdl_sub.script.types.map import Map -from ytdl_sub.script.types.resolvable import AnyArgument -from ytdl_sub.script.types.resolvable import Integer -from ytdl_sub.script.types.resolvable import ReturnableArgument -from ytdl_sub.script.types.resolvable import String -from ytdl_sub.script.utils.exceptions import RuntimeException -from ytdl_sub.utils.file_path import FilePathTruncater - - -def _pad(num: int, width: int): - return str(num).zfill(width) - - -_days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - - -class CustomFunctions: - @staticmethod - def legacy_bracket_safety(value: ReturnableArgument) -> ReturnableArgument: - if isinstance(value, String): - value = String(value.value.replace("{", "{").replace("}", "}")) - return value - - @staticmethod - def to_native_filepath(filepath: String) -> String: - return String(filepath.value.replace(posixpath.sep, os.sep)) - - @staticmethod - def truncate_filepath_if_too_long(filepath: String) -> String: - return String(FilePathTruncater.maybe_truncate_file_path(filepath.value)) - - @staticmethod - def sanitize(value: AnyArgument) -> String: - return String(sanitize_filename(str(value))) - - @staticmethod - def sanitize_plex_episode(string: String) -> String: - sanitized_string = CustomFunctions.sanitize(string).value - out = "" - for char in sanitized_string: - match char: - case "0": - out += "0" - case "1": - out += "1" - case "2": - out += "2" - case "3": - out += "3" - case "4": - out += "4" - case "5": - out += "5" - case "6": - out += "6" - case "7": - out += "7" - case "8": - out += "8" - case "9": - out += "9" - case _: - out += char - return String(out) - - @staticmethod - def to_date_metadata(yyyymmdd: String) -> Map: - date_str = yyyymmdd.value - if not (date_str.isnumeric() and len(date_str) == 8): - raise RuntimeException( - f"Expected input of date_metadata to be YYYYMMDD, but received {date_str}" - ) - - year: int = int(date_str[:4]) - month_padded: str = date_str[4:6] - day_padded: str = date_str[6:8] - - month: int = int(month_padded) - day: int = int(day_padded) - year_truncated: int = int(str(year)[-2:]) - - day_of_year: int = sum(_days_in_month[:month]) + day - total_days_in_month: int = _days_in_month[month] - total_days_in_year: int = 365 - if year % 4 == 0: - total_days_in_year += 1 - if month == 2: - total_days_in_month += 1 - if month > 2: - day_of_year += 1 - - day_of_year_reversed: int = total_days_in_year + 1 - day_of_year - month_reversed: int = 13 - month - day_reversed: int = total_days_in_month + 1 - day - - return Map( - { - String("date"): yyyymmdd, - String("date_standardized"): String(f"{year}-{month_padded}-{day_padded}"), - String("year"): Integer(year), - String("month"): Integer(month), - String("day"): Integer(day), - String("year_truncated"): String(year_truncated), - String("month_padded"): String(month_padded), - String("day_padded"): String(day_padded), - String("year_truncated_reversed"): Integer(100 - year_truncated), - String("month_reversed"): Integer(month_reversed), - String("month_reversed_padded"): String(_pad(month_reversed, width=2)), - String("day_reversed"): Integer(day_reversed), - String("day_reversed_padded"): String(_pad(day_reversed, width=2)), - String("day_of_year"): Integer(day_of_year), - String("day_of_year_padded"): String(_pad(day_of_year, width=3)), - String("day_of_year_reversed"): Integer(day_of_year_reversed), - String("day_of_year_reversed_padded"): String(_pad(day_of_year_reversed, width=3)), - } +CUSTOM_FUNCTION_SCRIPTS: Dict[str, str] = { + "%extract_field_from_metadata_array_getter": "{ %map_get( %map(%array_at($0, 0)), %array_at($0, 1) ) }", + "%extract_field_from_metadata_array": f"""{{ + %if( + %bool($0), + %array_extend( + %array_apply( + %array_product( + %array($0), + [ %string($1) ] + ), + %extract_field_from_metadata_array_getter + ) + ), + [] + ) + }}""", + "%extract_field_from_siblings": f"""{{ + %if( + %bool({v.sibling_metadata.variable_name}), + %extract_field_from_metadata_array( + {v.sibling_metadata.variable_name}, + $0 + ), + [] ) - - @staticmethod - def register(): - if not Functions.is_built_in("sanitize"): - Functions.register_function(CustomFunctions.legacy_bracket_safety) - Functions.register_function(CustomFunctions.truncate_filepath_if_too_long) - Functions.register_function(CustomFunctions.to_native_filepath) - Functions.register_function(CustomFunctions.sanitize) - Functions.register_function(CustomFunctions.sanitize_plex_episode) - Functions.register_function(CustomFunctions.to_date_metadata) + }}""", +} diff --git a/src/ytdl_sub/entries/script/variable_scripts.py b/src/ytdl_sub/entries/script/variable_scripts.py index d20e54e5..f2e2da40 100644 --- a/src/ytdl_sub/entries/script/variable_scripts.py +++ b/src/ytdl_sub/entries/script/variable_scripts.py @@ -5,7 +5,7 @@ from typing import Set import mergedeep -from ytdl_sub.entries.script.function_scripts import CustomFunctions +from ytdl_sub.entries.script.custom_functions import CustomFunctions from ytdl_sub.entries.script.variable_definitions import VARIABLES as v from ytdl_sub.entries.script.variable_definitions import Metadata from ytdl_sub.entries.script.variable_definitions import MetadataVariable @@ -35,8 +35,11 @@ def date_metadata(date_key: Variable, metadata_key: str) -> str: # Metadata Getters -def _get_internal( - metadata: Metadata, key: MetadataVariable, default: Optional[Variable | str | int | Dict | List] +def _get( + cast: str, + metadata: Metadata, + key: MetadataVariable, + default: Optional[Variable | str | int | Dict | List], ) -> str: if default is None: # TODO: assert with good error message if key DNE @@ -52,57 +55,51 @@ def _get_internal( else: out = f"%map_get_non_empty({metadata.variable_name}, '{key.metadata_key}', {default})" - return f"%legacy_bracket_safety({out})" - - -def _get_int( - metadata: Metadata, key: MetadataVariable, default: Optional[Variable | int] = None -) -> str: - return f"{{%int({_get_internal(metadata=metadata, key=key, default=default)})}}" - - -def _get( - metadata: Metadata, key: MetadataVariable, default: Optional[Variable | str | int | Dict | List] -) -> str: - return f"{{{_get_internal(metadata=metadata, key=key, default=default)}}}" + return f"{{ %legacy_bracket_safety(%{cast}({out})) }}" ############################################################################################### # Entry Getters -def entry_get( - key: MetadataVariable, default: Optional[Variable | str | int | Dict | List] = None -) -> str: - return _get(metadata=v.entry_metadata, key=key, default=default) +def entry_get_str(key: MetadataVariable, default: Optional[Variable | str] = None) -> str: + return _get("string", metadata=v.entry_metadata, key=key, default=default) def entry_get_int(key: MetadataVariable, default: Optional[Variable | int] = None) -> str: - return _get_int(metadata=v.entry_metadata, key=key, default=default) + return _get("int", metadata=v.entry_metadata, key=key, default=default) + + +def entry_get_map(key: MetadataVariable, default: Optional[Variable | Dict] = None): + return _get("map", metadata=v.entry_metadata, key=key, default=default) + + +def entry_get_array(key: MetadataVariable, default: Optional[Variable | List] = None): + return _get("array", metadata=v.entry_metadata, key=key, default=default) ############################################################################################### # Playlist Getters -def playlist_get(key: MetadataVariable, default: Optional[Variable | str | int] = None) -> str: - return _get(metadata=v.playlist_metadata, key=key, default=default) +def playlist_get_str(key: MetadataVariable, default: Optional[Variable | str] = None) -> str: + return _get("string", metadata=v.playlist_metadata, key=key, default=default) def playlist_get_int(key: MetadataVariable, default: Optional[Variable | int] = None) -> str: - return _get_int(metadata=v.playlist_metadata, key=key, default=default) + return _get("int", metadata=v.playlist_metadata, key=key, default=default) ############################################################################################### # Source Getters -def source_get(key: MetadataVariable, default: Optional[Variable | str | int] = None) -> str: - return _get(metadata=v.source_metadata, key=key, default=default) +def source_get_str(key: MetadataVariable, default: Optional[Variable | str] = None) -> str: + return _get("string", metadata=v.source_metadata, key=key, default=default) def source_get_int(key: MetadataVariable, default: Optional[Variable | int] = None) -> str: - return _get_int(metadata=v.source_metadata, key=key, default=default) + return _get("int", metadata=v.source_metadata, key=key, default=default) ############################################################################################### @@ -116,32 +113,32 @@ ENTRY_HARDCODED_VARIABLES: Dict[Variable, str] = { } ENTRY_RELATIVE_VARIABLES: Dict[MetadataVariable, str] = { - v.playlist_metadata: entry_get(v.playlist_metadata, {}), - v.source_metadata: entry_get(v.source_metadata, {}), - v.sibling_metadata: entry_get(v.sibling_metadata, []), + v.playlist_metadata: entry_get_map(v.playlist_metadata, {}), + v.source_metadata: entry_get_map(v.source_metadata, {}), + v.sibling_metadata: entry_get_array(v.sibling_metadata, []), } ENTRY_REQUIRED_VARIABLES: Dict[MetadataVariable, str] = { - v.uid: entry_get(v.uid), - v.extractor_key: entry_get(v.extractor_key), + v.uid: entry_get_str(v.uid), + v.extractor_key: entry_get_str(v.extractor_key), v.epoch: entry_get_int(v.epoch), - v.webpage_url: entry_get(v.webpage_url), - v.ext: entry_get(v.ext), + v.webpage_url: entry_get_str(v.webpage_url), + v.ext: entry_get_str(v.ext), } ENTRY_DEFAULT_VARIABLES: Dict[MetadataVariable, str] = { v.playlist_index: playlist_get_int(v.playlist_index, 1), - v.title: entry_get(v.title, v.uid), - v.extractor: entry_get(v.extractor, v.extractor_key), - v.description: entry_get(v.description, ""), - v.uploader_id: entry_get(v.uploader_id, v.uid), - v.uploader: entry_get(v.uploader, v.uploader_id), - v.uploader_url: entry_get(v.uploader_url, v.webpage_url), - v.upload_date: entry_get(v.upload_date, v.epoch_date), - v.release_date: entry_get(v.release_date, v.upload_date), - v.channel: entry_get(v.channel, v.uploader), - v.creator: entry_get(v.creator, v.channel), - v.channel_id: entry_get(v.channel_id, v.uploader_id), + v.title: entry_get_str(v.title, v.uid), + v.extractor: entry_get_str(v.extractor, v.extractor_key), + v.description: entry_get_str(v.description, ""), + v.uploader_id: entry_get_str(v.uploader_id, v.uid), + v.uploader: entry_get_str(v.uploader, v.uploader_id), + v.uploader_url: entry_get_str(v.uploader_url, v.webpage_url), + v.upload_date: entry_get_str(v.upload_date, v.epoch_date), + v.release_date: entry_get_str(v.release_date, v.upload_date), + v.channel: entry_get_str(v.channel, v.uploader), + v.creator: entry_get_str(v.creator, v.channel), + v.channel_id: entry_get_str(v.channel_id, v.uploader_id), } # MARK AS UNRESOLVABLE UNTIL THEY ARE ADDED @@ -212,14 +209,14 @@ ENTRY_RELEASE_DATE_VARIABLES: Dict[Variable, str] = { } PLAYLIST_VARIABLES: Dict[Variable, str] = { - v.playlist_uid: playlist_get(v.playlist_uid, v.uid), - v.playlist_title: playlist_get(v.playlist_title, v.title), - v.playlist_webpage_url: playlist_get(v.playlist_webpage_url, v.webpage_url), + v.playlist_uid: playlist_get_str(v.playlist_uid, v.uid), + v.playlist_title: playlist_get_str(v.playlist_title, v.title), + v.playlist_webpage_url: playlist_get_str(v.playlist_webpage_url, v.webpage_url), v.playlist_count: playlist_get_int(v.playlist_count, 1), - v.playlist_description: playlist_get(v.playlist_description, v.description), - v.playlist_uploader_id: playlist_get(v.playlist_uploader_id, v.uploader_id), - v.playlist_uploader: playlist_get(v.playlist_uploader, v.uploader), - v.playlist_uploader_url: playlist_get(v.playlist_uploader_url, v.playlist_webpage_url), + v.playlist_description: playlist_get_str(v.playlist_description, v.description), + v.playlist_uploader_id: playlist_get_str(v.playlist_uploader_id, v.uploader_id), + v.playlist_uploader: playlist_get_str(v.playlist_uploader, v.uploader), + v.playlist_uploader_url: playlist_get_str(v.playlist_uploader_url, v.playlist_webpage_url), } PLAYLIST_DERIVED_VARIABLES: Dict[Variable, str] = { @@ -234,15 +231,15 @@ PLAYLIST_DERIVED_VARIABLES: Dict[Variable, str] = { SOURCE_VARIABLES: Dict[Variable, str] = { - v.source_uid: source_get(v.source_uid, v.playlist_uid), - v.source_title: source_get(v.source_title, v.playlist_title), - v.source_webpage_url: source_get(v.source_webpage_url, v.playlist_webpage_url), + v.source_uid: source_get_str(v.source_uid, v.playlist_uid), + v.source_title: source_get_str(v.source_title, v.playlist_title), + v.source_webpage_url: source_get_str(v.source_webpage_url, v.playlist_webpage_url), v.source_index: source_get_int(v.source_index, 1), v.source_count: source_get_int(v.source_count, 1), - v.source_description: source_get(v.source_description, v.playlist_description), - v.source_uploader_id: source_get(v.source_uploader_id, v.playlist_uploader_id), - v.source_uploader: source_get(v.source_uploader, v.playlist_uploader), - v.source_uploader_url: source_get(v.source_uploader_url, v.source_webpage_url), + v.source_description: source_get_str(v.source_description, v.playlist_description), + v.source_uploader_id: source_get_str(v.source_uploader_id, v.playlist_uploader_id), + v.source_uploader: source_get_str(v.source_uploader, v.playlist_uploader), + v.source_uploader_url: source_get_str(v.source_uploader_url, v.source_webpage_url), } SOURCE_DERIVED_VARIABLES: Dict[Variable, str] = { @@ -251,7 +248,15 @@ SOURCE_DERIVED_VARIABLES: Dict[Variable, str] = { } SIBLING_VARIABLES: Dict[Variable, str] = { - v.playlist_max_upload_year: "TODO!!!", + v.playlist_max_upload_year: f"""{{ + %array_reduce( + %if_passthrough( + %extract_field_from_siblings('{v.upload_year.variable_name}'), + [{v.upload_year.variable_name}] + ), + %max + ) + }}""" } SIBLING_DERIVED_VARIABLES: Dict[Variable, str] = { @@ -270,6 +275,8 @@ mergedeep.merge( ENTRY_DERIVED_VARIABLES, ENTRY_UPLOAD_DATE_VARIABLES, ENTRY_RELEASE_DATE_VARIABLES, + SIBLING_VARIABLES, + SIBLING_DERIVED_VARIABLES, PLAYLIST_VARIABLES, PLAYLIST_DERIVED_VARIABLES, SOURCE_VARIABLES, diff --git a/src/ytdl_sub/script/functions/array_functions.py b/src/ytdl_sub/script/functions/array_functions.py index ba610e68..389d1d65 100644 --- a/src/ytdl_sub/script/functions/array_functions.py +++ b/src/ytdl_sub/script/functions/array_functions.py @@ -12,9 +12,22 @@ from ytdl_sub.script.types.resolvable import LambdaTwo from ytdl_sub.script.types.resolvable import Resolvable from ytdl_sub.script.utils.exceptions import UNREACHABLE from ytdl_sub.script.utils.exceptions import ArrayValueDoesNotExist +from ytdl_sub.script.utils.exceptions import FunctionRuntimeException class ArrayFunctions: + @staticmethod + def array(maybe_array: AnyArgument) -> Array: + if not isinstance(maybe_array, Array): + raise FunctionRuntimeException( + f"Tried and failed to cast {maybe_array.type_name()} as an Array" + ) + return maybe_array + + @staticmethod + def array_size(array: Array) -> Integer: + return Integer(len(array.value)) + @staticmethod def array_extend(*arrays: Array) -> Array: """ @@ -27,7 +40,7 @@ class ArrayFunctions: return Array(output) @staticmethod - def array_at(array: Array, idx: Integer) -> Resolvable: + def array_at(array: Array, idx: Integer) -> AnyArgument: """ Return the element in the Array at index ``idx``. """ diff --git a/src/ytdl_sub/script/functions/conditional_functions.py b/src/ytdl_sub/script/functions/conditional_functions.py index 291455a3..8caaae02 100644 --- a/src/ytdl_sub/script/functions/conditional_functions.py +++ b/src/ytdl_sub/script/functions/conditional_functions.py @@ -17,3 +17,15 @@ class ConditionalFunctions: if condition.value: return true return false + + @staticmethod + def if_passthrough( + maybe_true_arg: ReturnableArgumentA, else_arg: ReturnableArgumentB + ) -> Union[ReturnableArgumentA, ReturnableArgumentB]: + """ + Conditional ``if`` statement that returns the ``maybe_true_arg`` if it evaluates to True, + otherwise returns ``else_arg``. + """ + if bool(maybe_true_arg.value): + return maybe_true_arg + return else_arg diff --git a/src/ytdl_sub/script/functions/map_functions.py b/src/ytdl_sub/script/functions/map_functions.py index 10f7bca2..1aa5609f 100644 --- a/src/ytdl_sub/script/functions/map_functions.py +++ b/src/ytdl_sub/script/functions/map_functions.py @@ -9,17 +9,45 @@ from ytdl_sub.script.types.resolvable import Integer from ytdl_sub.script.types.resolvable import LambdaThree from ytdl_sub.script.types.resolvable import LambdaTwo from ytdl_sub.script.types.resolvable import String +from ytdl_sub.script.utils.exceptions import FunctionRuntimeException from ytdl_sub.script.utils.exceptions import KeyDoesNotExistRuntimeException +from ytdl_sub.script.utils.exceptions import KeyNotHashableRuntimeException class MapFunctions: @staticmethod - def map_get(mapping: Map, key: Hashable, default: Optional[AnyArgument] = None) -> AnyArgument: + def map(maybe_mapping: AnyArgument) -> Map: + if not isinstance(maybe_mapping, Map): + raise FunctionRuntimeException( + f"Tried and failed to cast {maybe_mapping.type_name()} as a Map" + ) + return maybe_mapping + + @staticmethod + def map_size(mapping: Map) -> Integer: + return Integer(len(mapping.value)) + + @staticmethod + def map_contains(mapping: Map, key: AnyArgument) -> Boolean: + """ + Returns True if the key is in the Map. False otherwise. + """ + if not isinstance(key, Hashable): + raise KeyNotHashableRuntimeException( + f"Tried to use {key.type_name()} as a Map key, but it is not hashable." + ) + + return Boolean(key in mapping.value) + + @staticmethod + def map_get( + mapping: Map, key: AnyArgument, default: Optional[AnyArgument] = None + ) -> AnyArgument: """ Return ``key``'s value within the Map. If ``key`` does not exist, and ``default`` is provided, it will return ``default``. Otherwise, will error. """ - if key not in mapping.value: + if not MapFunctions.map_contains(mapping=mapping, key=key).value: if default is not None: return default @@ -29,7 +57,7 @@ class MapFunctions: return mapping.value[key] @staticmethod - def map_get_non_empty(mapping: Map, key: Hashable, default: AnyArgument) -> AnyArgument: + def map_get_non_empty(mapping: Map, key: AnyArgument, default: AnyArgument) -> AnyArgument: """ Return ``key``'s value within the Map. If ``key`` does not exist or is an empty string, return ``default``. Otherwise, will error. @@ -39,13 +67,6 @@ class MapFunctions: return default return output - @staticmethod - def map_contains(mapping: Map, key: Hashable) -> Boolean: - """ - Returns True if the key is in the Map. False otherwise. - """ - return Boolean(key in mapping.value) - # pylint: disable=unused-argument @staticmethod diff --git a/src/ytdl_sub/script/types/function.py b/src/ytdl_sub/script/types/function.py index 5bdb541a..46b90def 100644 --- a/src/ytdl_sub/script/types/function.py +++ b/src/ytdl_sub/script/types/function.py @@ -14,6 +14,7 @@ from ytdl_sub.script.types.array import UnresolvedArray from ytdl_sub.script.types.resolvable import Argument from ytdl_sub.script.types.resolvable import BuiltInFunctionType from ytdl_sub.script.types.resolvable import FunctionType +from ytdl_sub.script.types.resolvable import FutureResolvable from ytdl_sub.script.types.resolvable import Lambda from ytdl_sub.script.types.resolvable import NamedCustomFunction from ytdl_sub.script.types.resolvable import Resolvable @@ -105,6 +106,8 @@ class BuiltInFunction(Function, BuiltInFunctionType): def _arg_output_type(cls, arg: Argument) -> Type[Argument]: if isinstance(arg, BuiltInFunction): return arg.output_type() + if isinstance(arg, FutureResolvable): + return arg.future_resolvable_type() return type(arg) @classmethod diff --git a/src/ytdl_sub/script/utils/type_checking.py b/src/ytdl_sub/script/utils/type_checking.py index 34562e8c..c6248ff9 100644 --- a/src/ytdl_sub/script/utils/type_checking.py +++ b/src/ytdl_sub/script/utils/type_checking.py @@ -67,7 +67,8 @@ def is_type_compatible( arg_type: Type[NamedType] = arg.__class__ if isinstance(arg, BuiltInFunctionType): arg_type = arg.output_type() # built-in function - elif isinstance(arg, FutureResolvable): + + if isinstance(arg, FutureResolvable): arg_type = arg.future_resolvable_type() elif isinstance(arg, FunctionType): return True # custom-function, can be anything, so pass for now diff --git a/src/ytdl_sub/utils/scriptable.py b/src/ytdl_sub/utils/scriptable.py index afaefb29..1c5ddb6e 100644 --- a/src/ytdl_sub/utils/scriptable.py +++ b/src/ytdl_sub/utils/scriptable.py @@ -4,6 +4,7 @@ from typing import Any from typing import Dict from typing import Set +from ytdl_sub.entries.script.function_scripts import CUSTOM_FUNCTION_SCRIPTS from ytdl_sub.entries.script.variable_scripts import UNRESOLVED_VARIABLES from ytdl_sub.entries.script.variable_scripts import VARIABLE_SCRIPTS from ytdl_sub.script.script import Script @@ -12,7 +13,9 @@ from ytdl_sub.utils.script import ScriptUtils class Scriptable(ABC): def __init__(self): - self.script = Script(copy.deepcopy(VARIABLE_SCRIPTS)) + self.script = Script( + dict(copy.deepcopy(VARIABLE_SCRIPTS), **copy.deepcopy(CUSTOM_FUNCTION_SCRIPTS)) + ) self.unresolvable: Set[str] = copy.deepcopy(UNRESOLVED_VARIABLES) def update_script(self) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 442eda31..b7ec75ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,7 @@ from resources import file_fixture_path from yt_dlp.utils import sanitize_filename from ytdl_sub.config.config_file import ConfigFile -from ytdl_sub.entries.script.function_scripts import CustomFunctions +from ytdl_sub.entries.script.custom_functions import CustomFunctions from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.logger import Logger diff --git a/tests/unit/script/types/test_array.py b/tests/unit/script/types/test_array.py index 837f5217..f816b1bd 100644 --- a/tests/unit/script/types/test_array.py +++ b/tests/unit/script/types/test_array.py @@ -15,21 +15,21 @@ from ytdl_sub.script.utils.exceptions import InvalidSyntaxException class TestArray: def test_return(self): - assert Script({"array": "{['a', 3.14]}"}).resolve() == ScriptOutput( - {"array": Array([String("a"), Float(3.14)])} + assert Script({"arr": "{['a', 3.14]}"}).resolve() == ScriptOutput( + {"arr": Array([String("a"), Float(3.14)])} ) def test_return_as_str(self): - assert Script({"array": "str: {['a', 3.14]}"}).resolve() == ScriptOutput( - {"array": String('str: ["a", 3.14]')} + assert Script({"arr": "str: {['a', 3.14]}"}).resolve() == ScriptOutput( + {"arr": String('str: ["a", 3.14]')} ) def test_nested_array(self): assert Script( - {"array": "{['level1', ['level2', ['level3', 'level3'], 'level2'], 'level1']}"} + {"arr": "{['level1', ['level2', ['level3', 'level3'], 'level2'], 'level1']}"} ).resolve() == ScriptOutput( { - "array": Array( + "arr": Array( [ String("level1"), Array( @@ -55,7 +55,7 @@ class TestArray: ], ) def test_empty(self, array: str): - assert Script({"array": array}).resolve() == ScriptOutput({"array": Array([])}) + assert Script({"arr": array}).resolve() == ScriptOutput({"arr": Array([])}) @pytest.mark.parametrize( "array", @@ -73,7 +73,7 @@ class TestArray: InvalidSyntaxException, match=re.escape(str(_UNEXPECTED_COMMA_ARGUMENT(ParsedArgType.ARRAY))), ): - Script({"array": array}).resolve() + Script({"arr": array}).resolve() @pytest.mark.parametrize( "array", @@ -90,7 +90,7 @@ class TestArray: InvalidSyntaxException, match=re.escape(str(_UNEXPECTED_CHAR_ARGUMENT(ParsedArgType.ARRAY))), ): - assert Script({"array": array}).resolve() + assert Script({"arr": array}).resolve() @pytest.mark.parametrize( "array", @@ -104,7 +104,7 @@ class TestArray: InvalidSyntaxException, match=re.escape(str(_UNEXPECTED_CHAR_ARGUMENT(ParsedArgType.SCRIPT))), ): - assert Script({"array": array}).resolve() + assert Script({"arr": array}).resolve() def test_custom_function(self): assert Script( diff --git a/tests/unit/script/types/test_function.py b/tests/unit/script/types/test_function.py index 7dc07edc..19d2e9d8 100644 --- a/tests/unit/script/types/test_function.py +++ b/tests/unit/script/types/test_function.py @@ -40,7 +40,7 @@ class TestFunction: with pytest.raises( IncompatibleFunctionArguments, match=_incompatible_arguments_match( - expected="Map, Hashable, Optional[AnyArgument]", + expected="Map, AnyArgument, Optional[AnyArgument]", recieved="%if(...)->Union[Array, Map], String", ), ): diff --git a/tests/unit/script/types/test_map.py b/tests/unit/script/types/test_map.py index 10faa962..a894b6b5 100644 --- a/tests/unit/script/types/test_map.py +++ b/tests/unit/script/types/test_map.py @@ -20,13 +20,13 @@ from ytdl_sub.script.utils.exceptions import KeyNotHashableRuntimeException class TestMap: def test_return(self): - assert Script({"map": "{{'a': 3.14}}"}).resolve() == ScriptOutput( - {"map": Map({String("a"): Float(3.14)})} + assert Script({"dict": "{{'a': 3.14}}"}).resolve() == ScriptOutput( + {"dict": Map({String("a"): Float(3.14)})} ) def test_return_as_str(self): - assert Script({"map": "json: {{'a': 3.14}}"}).resolve() == ScriptOutput( - {"map": String('json: {"a": 3.14}')} + assert Script({"dict": "json: {{'a': 3.14}}"}).resolve() == ScriptOutput( + {"dict": String('json: {"a": 3.14}')} ) def test_nested_map(self): @@ -45,9 +45,9 @@ class TestMap: } }""" - assert Script({"map": map_str}).resolve() == ScriptOutput( + assert Script({"dict": map_str}).resolve() == ScriptOutput( { - "map": Map( + "dict": Map( { String("level1"): Map( { @@ -78,7 +78,7 @@ class TestMap: ], ) def test_empty_map(self, empty_map: str): - assert Script({"map": empty_map}).resolve() == ScriptOutput({"map": Map({})}) + assert Script({"dict": empty_map}).resolve() == ScriptOutput({"dict": Map({})}) @pytest.mark.parametrize( "map", @@ -93,7 +93,7 @@ class TestMap: ) def test_map_not_closed(self, map: str): with pytest.raises(InvalidSyntaxException, match=re.escape(str(BRACKET_NOT_CLOSED))): - Script({"map": map}).resolve() + Script({"dict": map}).resolve() @pytest.mark.parametrize( "value", @@ -108,7 +108,7 @@ class TestMap: ) def test_key_has_no_value(self, value: str): with pytest.raises(InvalidSyntaxException, match=re.escape(str(MAP_KEY_WITH_NO_VALUE))): - Script({"map": value}).resolve() + Script({"dict": value}).resolve() @pytest.mark.parametrize( "value", @@ -124,7 +124,7 @@ class TestMap: InvalidSyntaxException, match=re.escape(str(_UNEXPECTED_COMMA_ARGUMENT(ParsedArgType.MAP_KEY))), ): - Script({"map": value}).resolve() + Script({"dict": value}).resolve() @pytest.mark.parametrize( "value", @@ -136,7 +136,7 @@ class TestMap: ) def test_map_multiple_keys(self, value: str): with pytest.raises(InvalidSyntaxException, match=re.escape(str(MAP_KEY_MULTIPLE_VALUES))): - Script({"map": value}).resolve() + Script({"dict": value}).resolve() @pytest.mark.parametrize( "value", @@ -148,7 +148,7 @@ class TestMap: ) def test_map_missing_key(self, value: str): with pytest.raises(InvalidSyntaxException, match=re.escape(str(MAP_MISSING_KEY))): - Script({"map": value}).resolve() + Script({"dict": value}).resolve() @pytest.mark.parametrize( "value", @@ -161,18 +161,18 @@ class TestMap: ) def test_map_key_not_hashable(self, value: str): with pytest.raises(InvalidSyntaxException, match=re.escape(str(MAP_KEY_NOT_HASHABLE))): - Script({"map": value}).resolve() + Script({"dict": value}).resolve() def test_map_key_is_hashable_variable(self): assert Script( { - "map": "{{key_variable : 'value' }}", + "dict": "{{key_variable : 'value' }}", "key_variable": "hashable", } ).resolve() == ScriptOutput( { "key_variable": String("hashable"), - "map": Map({String("hashable"): String("value")}), + "dict": Map({String("hashable"): String("value")}), } ) @@ -183,7 +183,7 @@ class TestMap: ): Script( { - "map": "{{key_variable : 'value' }}", + "dict": "{{key_variable : 'value' }}", "key_variable": "{['non-hashable']}", } ).resolve()