function type hinting

This commit is contained in:
Jesse Bannon 2023-12-20 00:49:46 -08:00
parent 1b2c449331
commit fe6bcadc0d
6 changed files with 84 additions and 33 deletions

2
.gitignore vendored
View file

@ -149,3 +149,5 @@ docker/testing/volumes
ffmpeg.exe ffmpeg.exe
ffprobe.exe ffprobe.exe
tools/docgen/out

View file

@ -1,16 +1,11 @@
import sys import sys
from typing import List from typing import List
from typing import Type
from typing import TypeVar from typing import TypeVar
from typing import Union
from ytdl_sub.script.types.resolvable import BuiltInFunctionType 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 IncompatibleFunctionArguments
from ytdl_sub.script.utils.exceptions import UserException 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 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 from ytdl_sub.script.utils.type_checking import is_union
TUserException = TypeVar("TUserException", bound=UserException) TUserException = TypeVar("TUserException", bound=UserException)
@ -103,28 +98,10 @@ class FunctionArgumentsExceptionFormatter:
input_spec: FunctionSpec, input_spec: FunctionSpec,
function_instance: BuiltInFunctionType, function_instance: BuiltInFunctionType,
): ):
self._args = input_spec.args self._input_spec = input_spec
self._varargs = input_spec.varargs
self._name = function_instance.name self._name = function_instance.name
self._input_args = function_instance.args 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: def _received_args_str(self) -> str:
received_type_names: List[str] = [] received_type_names: List[str] = []
for arg in self._input_args: for arg in self._input_args:
@ -149,5 +126,6 @@ class FunctionArgumentsExceptionFormatter:
""" """
return IncompatibleFunctionArguments( return IncompatibleFunctionArguments(
f"Incompatible arguments passed to function {self._name}.\n" 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()}"
) )

View file

@ -119,6 +119,7 @@ def is_type_compatible(
@dataclass(frozen=True) @dataclass(frozen=True)
class FunctionSpec: class FunctionSpec:
return_type: Type[Resolvable] return_type: Type[Resolvable]
arg_names: List[str]
args: Optional[List[Type[Resolvable | Optional[Resolvable]]]] = None args: Optional[List[Type[Resolvable | Optional[Resolvable]]]] = None
varargs: Optional[Type[Resolvable]] = None varargs: Optional[Type[Resolvable]] = None
@ -223,6 +224,42 @@ class FunctionSpec:
return l_type return l_type
return None 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 @classmethod
def from_callable(cls, callable_ref: Callable[..., Resolvable]) -> "FunctionSpec": def from_callable(cls, callable_ref: Callable[..., Resolvable]) -> "FunctionSpec":
""" """
@ -234,10 +271,12 @@ class FunctionSpec:
if arg_spec.varargs: if arg_spec.varargs:
return FunctionSpec( return FunctionSpec(
return_type=arg_spec.annotations["return"], return_type=arg_spec.annotations["return"],
arg_names=[arg_spec.varargs],
varargs=arg_spec.annotations[arg_spec.varargs], varargs=arg_spec.annotations[arg_spec.varargs],
) )
return FunctionSpec( return FunctionSpec(
return_type=arg_spec.annotations["return"], return_type=arg_spec.annotations["return"],
arg_names=arg_spec.args,
args=[arg_spec.annotations[arg_name] for arg_name in arg_spec.args], args=[arg_spec.annotations[arg_name] for arg_name in arg_spec.args],
) )

View file

@ -40,7 +40,7 @@ class TestFunction:
with pytest.raises( with pytest.raises(
IncompatibleFunctionArguments, IncompatibleFunctionArguments,
match=_incompatible_arguments_match( match=_incompatible_arguments_match(
expected="Map, AnyArgument, Optional[AnyArgument]", expected="mapping: Map, key: AnyArgument, default: Optional[AnyArgument]",
recieved="%if(...)->Union[Array, Map], String", recieved="%if(...)->Union[Array, Map], String",
), ),
): ):
@ -49,11 +49,11 @@ class TestFunction:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"function_str, expected_types, received_types", "function_str, expected_types, received_types",
[ [
("{%array_at({'a': 'dict?'}, 1)}", "Array, Integer", "Map, Integer"), ("{%array_at({'a': 'dict?'}, 1)}", "array: Array, idx: Integer", "Map, Integer"),
("{%array_extend('not', 'array')}", "Array, ...", "String, String"), ("{%array_extend('not', 'array')}", "arrays: Array, ...", "String, String"),
( (
"{%replace('hi mom', 'mom', 'dad', 1, 0)}", "{%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", "String, String, String, Integer, Integer",
), ),
], ],

View file

@ -1,14 +1,16 @@
import inspect
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import Optional from typing import Optional
from typing import Type from typing import Type
from tools.docgen.utils import camel_case_to_human 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 section
from tools.docgen.utils import static_methods 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.entries.script.custom_functions import CustomFunctions
from ytdl_sub.script.functions import Functions 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]: 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__) 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: def generate_function_docs() -> str:
docs = section("Scripting Functions", level=0) docs = section("Scripting Functions", level=0)
@ -38,14 +64,14 @@ def generate_function_docs() -> str:
for function_name in static_methods(parent_objs[name]): for function_name in static_methods(parent_objs[name]):
if display_function_name := maybe_get_function_name(function_name): if display_function_name := maybe_get_function_name(function_name):
docs += get_function_docs( docs += get_function_docstring(
function_name=function_name, function_name=function_name,
display_function_name=display_function_name, display_function_name=display_function_name,
obj=parent_objs[name], function=getattr(parent_objs[name], function_name),
level=2, level=2,
) )
return docs return docs
print(generate_function_docs()) to_out_dir(name="scripting_functions.rst", docs=generate_function_docs())

View file

@ -1,4 +1,5 @@
import inspect import inspect
from pathlib import Path
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import List from typing import List
@ -42,3 +43,8 @@ def get_function_docs(
docs += inspect.cleandoc(getattr(obj, function_name).__doc__) docs += inspect.cleandoc(getattr(obj, function_name).__doc__)
docs += "\n" docs += "\n"
return docs 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)