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
ffprobe.exe
tools/docgen/out

View file

@ -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()}"
)

View file

@ -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],
)

View file

@ -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",
),
],

View file

@ -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())

View file

@ -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)