diff --git a/.gitignore b/.gitignore index 20d1b188..e18f90d3 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,5 @@ docker/testing/volumes ffmpeg.exe ffprobe.exe + +tools/docgen/out \ No newline at end of file diff --git a/src/ytdl_sub/script/utils/exception_formatters.py b/src/ytdl_sub/script/utils/exception_formatters.py index fb3b5e6e..6e14354e 100644 --- a/src/ytdl_sub/script/utils/exception_formatters.py +++ b/src/ytdl_sub/script/utils/exception_formatters.py @@ -1,16 +1,11 @@ import sys from typing import List -from typing import Type from typing import TypeVar -from typing import Union from ytdl_sub.script.types.resolvable import BuiltInFunctionType -from ytdl_sub.script.types.resolvable import NamedType from ytdl_sub.script.utils.exceptions import IncompatibleFunctionArguments from ytdl_sub.script.utils.exceptions import UserException from ytdl_sub.script.utils.type_checking import FunctionSpec -from ytdl_sub.script.utils.type_checking import get_optional_type -from ytdl_sub.script.utils.type_checking import is_optional from ytdl_sub.script.utils.type_checking import is_union TUserException = TypeVar("TUserException", bound=UserException) @@ -103,28 +98,10 @@ class FunctionArgumentsExceptionFormatter: input_spec: FunctionSpec, function_instance: BuiltInFunctionType, ): - self._args = input_spec.args - self._varargs = input_spec.varargs + self._input_spec = input_spec self._name = function_instance.name self._input_args = function_instance.args - @classmethod - def _to_human_readable_name(cls, python_type: Type[NamedType] | Type[Union[NamedType]]) -> str: - if is_optional(python_type): - return f"Optional[{cls._to_human_readable_name(get_optional_type(python_type))}]" - if is_union(python_type): - return ", ".join( - sorted(cls._to_human_readable_name(arg) for arg in python_type.__args__) - ) - return python_type.type_name() - - def _expected_args_str(self) -> str: - if self._args is not None: - return f"({', '.join([self._to_human_readable_name(type_) for type_ in self._args])})" - if self._varargs is not None: - return f"({self._to_human_readable_name(self._varargs)}, ...)" - return "()" - def _received_args_str(self) -> str: received_type_names: List[str] = [] for arg in self._input_args: @@ -149,5 +126,6 @@ class FunctionArgumentsExceptionFormatter: """ return IncompatibleFunctionArguments( f"Incompatible arguments passed to function {self._name}.\n" - f"Expected {self._expected_args_str()}\nReceived {self._received_args_str()}" + f"Expected {self._input_spec.human_readable_input_args()}\n" + f"Received {self._received_args_str()}" ) diff --git a/src/ytdl_sub/script/utils/type_checking.py b/src/ytdl_sub/script/utils/type_checking.py index cc7d87ff..2170c3e0 100644 --- a/src/ytdl_sub/script/utils/type_checking.py +++ b/src/ytdl_sub/script/utils/type_checking.py @@ -119,6 +119,7 @@ def is_type_compatible( @dataclass(frozen=True) class FunctionSpec: return_type: Type[Resolvable] + arg_names: List[str] args: Optional[List[Type[Resolvable | Optional[Resolvable]]]] = None varargs: Optional[Type[Resolvable]] = None @@ -223,6 +224,42 @@ class FunctionSpec: return l_type return None + @classmethod + def _to_human_readable_name(cls, python_type: Type[NamedType] | Type[Union[NamedType]]) -> str: + if is_optional(python_type): + return f"Optional[{cls._to_human_readable_name(get_optional_type(python_type))}]" + if is_union(python_type): + args = ", ".join( + sorted(cls._to_human_readable_name(arg) for arg in python_type.__args__) + ) + return f"Union[{args}]" + return python_type.type_name() + + def human_readable_input_args(self) -> str: + """ + Returns + ------- + input arg string in human-readable format + """ + if self.args is not None: + args = ", ".join( + f"{name}: {self._to_human_readable_name(type_)}" + for name, type_ in zip(self.arg_names, self.args) + ) + elif self.varargs is not None: + args = f"{self.arg_names[0]}: {self._to_human_readable_name(self.varargs)}, ..." + else: + args = "" + return f"({args})" + + def human_readable_output_type(self) -> str: + """ + Returns + ------- + output type string in human-readable format + """ + return self._to_human_readable_name(self.return_type) + @classmethod def from_callable(cls, callable_ref: Callable[..., Resolvable]) -> "FunctionSpec": """ @@ -234,10 +271,12 @@ class FunctionSpec: if arg_spec.varargs: return FunctionSpec( return_type=arg_spec.annotations["return"], + arg_names=[arg_spec.varargs], varargs=arg_spec.annotations[arg_spec.varargs], ) return FunctionSpec( return_type=arg_spec.annotations["return"], + arg_names=arg_spec.args, args=[arg_spec.annotations[arg_name] for arg_name in arg_spec.args], ) diff --git a/tests/unit/script/types/test_function.py b/tests/unit/script/types/test_function.py index 1331d485..014e4a85 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, AnyArgument, Optional[AnyArgument]", + expected="mapping: Map, key: AnyArgument, default: Optional[AnyArgument]", recieved="%if(...)->Union[Array, Map], String", ), ): @@ -49,11 +49,11 @@ class TestFunction: @pytest.mark.parametrize( "function_str, expected_types, received_types", [ - ("{%array_at({'a': 'dict?'}, 1)}", "Array, Integer", "Map, Integer"), - ("{%array_extend('not', 'array')}", "Array, ...", "String, String"), + ("{%array_at({'a': 'dict?'}, 1)}", "array: Array, idx: Integer", "Map, Integer"), + ("{%array_extend('not', 'array')}", "arrays: Array, ...", "String, String"), ( "{%replace('hi mom', 'mom', 'dad', 1, 0)}", - "String, String, String, Optional[Integer]", + "string: String, old: String, new: String, count: Optional[Integer]", "String, String, String, Integer, Integer", ), ], diff --git a/tools/docgen/functions.py b/tools/docgen/functions.py index 00383a17..39cb152b 100644 --- a/tools/docgen/functions.py +++ b/tools/docgen/functions.py @@ -1,14 +1,16 @@ +import inspect from typing import Any from typing import Dict from typing import Optional from typing import Type from tools.docgen.utils import camel_case_to_human -from tools.docgen.utils import get_function_docs from tools.docgen.utils import section from tools.docgen.utils import static_methods +from tools.docgen.utils import to_out_dir from ytdl_sub.entries.script.custom_functions import CustomFunctions from ytdl_sub.script.functions import Functions +from ytdl_sub.script.utils.type_checking import FunctionSpec def maybe_get_function_name(function_name: str) -> Optional[str]: @@ -25,6 +27,30 @@ def function_class_to_name(obj: Type[Any]) -> str: return camel_case_to_human(obj.__name__) +def function_type_hinting(display_function_name: str, function: Any) -> str: + spec = FunctionSpec.from_callable(function) + out = "``" + out += display_function_name + out += spec.human_readable_input_args() + out += " -> " + out += spec.human_readable_output_type() + out += "``\n\n" + return out + + +def get_function_docstring( + function_name: str, function: Any, level: int, display_function_name: Optional[str] = None +) -> str: + display_function_name = display_function_name if display_function_name else function_name + + docs = section(display_function_name, level=level) + + docs += function_type_hinting(display_function_name=display_function_name, function=function) + docs += inspect.cleandoc(function.__doc__) + docs += "\n" + return docs + + def generate_function_docs() -> str: docs = section("Scripting Functions", level=0) @@ -38,14 +64,14 @@ def generate_function_docs() -> str: for function_name in static_methods(parent_objs[name]): if display_function_name := maybe_get_function_name(function_name): - docs += get_function_docs( + docs += get_function_docstring( function_name=function_name, display_function_name=display_function_name, - obj=parent_objs[name], + function=getattr(parent_objs[name], function_name), level=2, ) return docs -print(generate_function_docs()) +to_out_dir(name="scripting_functions.rst", docs=generate_function_docs()) diff --git a/tools/docgen/utils.py b/tools/docgen/utils.py index d236bb5f..a94fb93c 100644 --- a/tools/docgen/utils.py +++ b/tools/docgen/utils.py @@ -1,4 +1,5 @@ import inspect +from pathlib import Path from typing import Any from typing import Dict from typing import List @@ -42,3 +43,8 @@ def get_function_docs( docs += inspect.cleandoc(getattr(obj, function_name).__doc__) docs += "\n" return docs + + +def to_out_dir(name: str, docs: str) -> None: + with open(Path("tools") / "docgen" / "out" / name, "w", encoding="utf-8") as out: + out.write(docs)