diff --git a/src/psyclone/docstring_parser.py b/src/psyclone/docstring_parser.py index 7e886b3ba4..786e0d7762 100644 --- a/src/psyclone/docstring_parser.py +++ b/src/psyclone/docstring_parser.py @@ -59,6 +59,8 @@ Marcin Kurczewski - https://github.com/rr-/docstring_parser. ''' +from __future__ import annotations + from collections import OrderedDict from dataclasses import dataclass import inspect @@ -85,16 +87,22 @@ class ArgumentData(): desc: str inline_type: bool - def gen_docstring(self, function: Union[None, Callable[..., Any]] = None)\ - -> str: + def gen_docstring(self, function: Union[None, Callable[..., Any]] = None, + cls_name: str = "") -> str: ''' :param function: The function who the generated docstring will be for. Default option is None. If no function is supplied, there can be no type annotation and so the type information is included inline in the :param or in a separate :type entry. + :param cls_name: The cls_name to use for saying which subclass (or + otherwise) this argument is from. Used for documenting + sub transformation options. + :returns: The docstring represented by this ArgumentData. ''' + if cls_name: + cls_name = f"(Option used for {cls_name}) " rstr = ":param " if function: # If the argument is in function's parameter list and has a type @@ -103,13 +111,13 @@ def gen_docstring(self, function: Union[None, Callable[..., Any]] = None)\ val = signature.parameters.get(self.name) if (val is not None and val.annotation is not inspect.Parameter.empty): - rstr += f"{self.name}: {self.desc}" + rstr += f"{self.name}: {cls_name}{self.desc}" return rstr if self.inline_type: - rstr += f"{self.datatype} {self.name}: {self.desc}" + rstr += f"{self.datatype} {self.name}: {cls_name}{self.desc}" else: - rstr += f"{self.name}: {self.desc}{os.linesep}" + rstr += f"{self.name}: {cls_name}{self.desc}{os.linesep}" rstr += f":type {self.name}: {self.datatype}" return rstr @@ -171,6 +179,7 @@ class DocstringData(): arguments: OrderedDict raises: list returns: ReturnsData + sub_arguments: OrderedDict[str, OrderedDict] def add_data(self, docstring_element: Union[ArgumentData, RaisesData, ReturnsData, str]) -> None: @@ -200,7 +209,32 @@ def add_data(self, docstring_element: f"'{docstring_element}'." ) - def merge(self, other_data, replace_desc: bool = False, + def add_subarguments(self, cls_name: str, other_data: "DocstringData", + replace_args: bool = False): + '''Add the other DocstringData's arguments as sub arguments to be + referenced via cls_name. + + :param cls_name: The class name to use for the sub arguments. + :param other_data: The DocstringData object whose arguments to add + as sub arguments for this object. + :param replace_args: whether to replace duplicate sub arguments if + found. + ''' + # If the class isn't already in the sub arguments list, we just + # add a copy of the other data's argument dicts to the sub_arguments. + if cls_name not in self.sub_arguments: + self.sub_arguments[cls_name] = other_data.arguments.copy() + return + # Otherwise we need to add them manually. + for arg in other_data.arguments: + # If the arg is already present and we're not overwriting then + # skip. + if (arg in self.sub_arguments[cls_name].keys() and + not replace_args): + continue + self.sub_arguments[cls_name][arg] = other_data.arguments[arg] + + def merge(self, other_data: "DocstringData", replace_desc: bool = False, replace_args: bool = False, replace_returns: bool = False): ''' Merges the other_data DocstringData object into this one. @@ -211,7 +245,6 @@ def merge(self, other_data, replace_desc: bool = False, always. :param other_data: the DocstringData object to merge into this object. - :type other_data: :py:class:`psyclone.docstring_parser.DocstringData` :param replace_desc: whether to replace the desc with that of other_data. :param replace_args: whether to replace duplicate arguments with that @@ -239,6 +272,23 @@ def merge(self, other_data, replace_desc: bool = False, and other_data.returns is not None): self.returns = other_data.returns + # Merge the sub_arguments. + for subarg in other_data.sub_arguments: + # If the sub argument isn't in our own sub arguments, make a copy + # of the sub argument. + if subarg not in self.sub_arguments.keys(): + self.sub_arguments[subarg] = \ + other_data.sub_arguments[subarg].copy() + else: + # Otherwise we merge the sub arguments in the same fashion we + # merge the arguments. + for arg in other_data.sub_arguments[subarg]: + if (arg in self.sub_arguments[subarg].keys() + and not replace_args): + continue + self.sub_arguments[subarg][arg] = \ + other_data.sub_arguments[subarg][arg] + def gen_docstring( self, indentation: str = " ", function: Union[None, Callable[..., Any]] = None @@ -285,6 +335,24 @@ def gen_docstring( else: argstring = indentation + argstring argstrings.append(argstring) + for cls in self.sub_arguments: + for arg in self.sub_arguments[cls]: + argstring = self.sub_arguments[cls][arg].gen_docstring( + cls_name=cls + ) + if os.linesep in argstring: + lines = argstring.split(os.linesep) + argstring = indentation + lines[0] + os.linesep + for line in lines[1:]: + if ":type" not in line: + argstring += indentation*2 + line + os.linesep + else: + argstring += indentation + line + os.linesep + # Remove the last newline character + argstring = argstring[:-1] + else: + argstring = indentation + argstring + argstrings.append(argstring) raisestrings = [] for element in self.raises: @@ -375,7 +443,7 @@ def create_from_object(cls, obj: Any): rtype = None docstring_data = DocstringData( desc="", arguments=OrderedDict(), raises=[], - returns=None + returns=None, sub_arguments=OrderedDict() ) docstring_data.add_data(desc_chunk) diff --git a/src/psyclone/tests/docstring_parser_test.py b/src/psyclone/tests/docstring_parser_test.py index 316b0219ce..5960c12e88 100644 --- a/src/psyclone/tests/docstring_parser_test.py +++ b/src/psyclone/tests/docstring_parser_test.py @@ -76,19 +76,21 @@ def test_docstringdata_base(): arguments = OrderedDict() returns = ReturnsData(desc="desc", datatype="datatype") raises = [] + subargs = OrderedDict() docdata = DocstringData(desc="desc", arguments=arguments, raises=raises, - returns=returns) + returns=returns, sub_arguments=subargs) assert docdata.desc == "desc" assert docdata.arguments is arguments assert docdata.raises is raises assert docdata.returns is returns + assert docdata.sub_arguments is subargs def test_docstringdata_add_data(): 'Test the add_data function of the DocstringData dataclass.''' docdata = DocstringData(desc=None, arguments=OrderedDict(), raises=[], - returns=None) + returns=None, sub_arguments=OrderedDict()) docdata.add_data("desc") assert docdata.desc == "desc" @@ -124,10 +126,10 @@ def test_docstringdata_add_data(): def test_docstringdata_merge(): '''Test the merge function of the DocstringData dataclass.''' docdata = DocstringData(desc=None, arguments=OrderedDict(), raises=[], - returns=None) + returns=None, sub_arguments=OrderedDict()) docdata2 = DocstringData(desc=None, arguments=OrderedDict(), raises=[], - returns=None) + returns=None, sub_arguments=OrderedDict()) adata = ArgumentData(name="name", datatype="datatype", desc="desc", inline_type=True) docdata2.add_data(adata) @@ -142,7 +144,7 @@ def test_docstringdata_merge(): # Check we don't overwrite arguments without replace set. docdata3 = DocstringData(desc=None, arguments=OrderedDict(), raises=[], - returns=None) + returns=None, sub_arguments=OrderedDict()) adata3 = ArgumentData(name="name", datatype="datatype", desc="desc", inline_type=True) docdata3.add_data(adata3) @@ -153,13 +155,13 @@ def test_docstringdata_merge(): # Merge a description. docdata4 = DocstringData(desc="desc", arguments=OrderedDict(), raises=[], - returns=None) + returns=None, sub_arguments=OrderedDict()) docdata.merge(docdata4) assert docdata.desc == "desc" # Don't overwrite without param docdata5 = DocstringData(desc="desc2", arguments=OrderedDict(), raises=[], - returns=None) + returns=None, sub_arguments=OrderedDict()) docdata.merge(docdata5) assert docdata.desc == "desc" docdata.merge(docdata5, replace_desc=True) @@ -167,7 +169,7 @@ def test_docstringdata_merge(): # Merge raises docdata6 = DocstringData(desc="desc", arguments=OrderedDict(), raises=[], - returns=None) + returns=None, sub_arguments=OrderedDict()) rdata = RaisesData(desc="desc2", exception="Error") docdata6.add_data(rdata) docdata.merge(docdata6) @@ -175,7 +177,7 @@ def test_docstringdata_merge(): # Merge returns docdata7 = DocstringData(desc="desc", arguments=OrderedDict(), raises=[], - returns=None) + returns=None, sub_arguments=OrderedDict()) rdata = ReturnsData(desc="desc", datatype="datatype") docdata7.add_data(rdata) docdata.merge(docdata7) @@ -183,7 +185,7 @@ def test_docstringdata_merge(): # Don't overwrite without param docdata8 = DocstringData(desc="desc", arguments=OrderedDict(), raises=[], - returns=None) + returns=None, sub_arguments=OrderedDict()) rdata2 = ReturnsData(desc="desc2", datatype="datatype") docdata8.add_data(rdata2) docdata.merge(docdata8) @@ -191,6 +193,33 @@ def test_docstringdata_merge(): docdata.merge(docdata8, replace_returns=True) assert docdata.returns is rdata2 + # Test merging of sub arguments. + subargs1 = {"Subclass1": {"opt1": "Test opt1"}} + subargs2 = {"Subclass2": {"opt2": "Test opt2"}} + docdata = DocstringData(desc="desc", arguments=OrderedDict(), raises=[], + returns=None, sub_arguments=subargs1) + docdata2 = DocstringData(desc="desc", arguments=OrderedDict(), raises=[], + returns=None, sub_arguments=subargs2) + + docdata.merge(docdata2) + assert docdata.sub_arguments["Subclass2"] == {"opt2": "Test opt2"} + # Modify subargs2[Subclass2] to show its not modifying our docdata. + subargs2["Subclass2"]["opt3"] = "Test opt3" + assert docdata.sub_arguments["Subclass2"] == {"opt2": "Test opt2"} + + subargs3 = {"Subclass1": {"opt1": "Not test opt1", + "opt3": "Test opt 3"}} + docdata3 = DocstringData(desc="desc", arguments=OrderedDict(), raises=[], + returns=None, sub_arguments=subargs3) + # Check the merging without replace_args doesn't change things. + docdata.merge(docdata3) + assert docdata.sub_arguments["Subclass1"]["opt1"] == "Test opt1" + assert docdata.sub_arguments["Subclass1"]["opt3"] == "Test opt 3" + + # Check that merging with replace_args does change things. + docdata.merge(docdata3, replace_args=True) + assert docdata.sub_arguments["Subclass1"]["opt1"] == "Not test opt1" + def dummy_function(typed_arg: int, untyped_arg): '''Dummy function for testing functionality.''' @@ -316,7 +345,7 @@ def test_DocstringData_gen_docstring_(): # Check we get nothing for an empty DocstringData docdata = DocstringData(desc=None, arguments=OrderedDict(), raises=[], - returns=None) + returns=None, sub_arguments=OrderedDict()) output = docdata.gen_docstring() assert output == "" @@ -594,3 +623,67 @@ def test_function(param: DocstringData): assert isinstance(data, ArgumentData) assert (data.datatype == "") + + +def test_subarguments(): + ''' + Test that we can add sub arguments to a docstring data as expected. + ''' + + def docstringobj(arg1: int): + ''' + description. + + :param arg1: my param + ''' + + def subobject(arg1: int, arg2: int): + ''' + subobject description + + :param arg1: sub param + :param arg2: arg2 + ''' + + def subobject2(arg3, arg2: int): + ''' + subobject2 description + + :param arg2: subobj2 arg2 + :param arg3: arg3 + :type arg3: int + ''' + + doc1 = DocstringData.create_from_object(docstringobj) + + # Should have no sub arguments + assert doc1.sub_arguments == {} + + doc2 = DocstringData.create_from_object(subobject) + assert "arg1" in doc2.arguments + assert "arg2" in doc2.arguments + assert len(doc2.arguments) == 2 + + # Add doc2 as subarguments to doc1 + doc1.add_subarguments("subobject", doc2, replace_args=False) + + # Should only have arg2 in the sub arguments + assert len(doc1.sub_arguments["subobject"]) == 2 + assert doc1.sub_arguments["subobject"]["arg1"].desc == "sub param" + assert doc1.sub_arguments["subobject"]["arg2"].desc == "arg2" + + doc3 = DocstringData.create_from_object(subobject2) + # Add doc3 also as subobject to doc1 without replace_args + doc1.add_subarguments("subobject", doc3, replace_args=False) + assert len(doc1.sub_arguments["subobject"]) == 3 + assert doc1.sub_arguments["subobject"]["arg1"].desc == "sub param" + assert doc1.sub_arguments["subobject"]["arg2"].desc == "arg2" + assert doc1.sub_arguments["subobject"]["arg3"].desc == "arg3" + + # Add doc 3 as a subojbject with replace_args + # Add doc3 also as subobject to doc1 without replace_args + doc1.add_subarguments("subobject", doc3, replace_args=True) + assert len(doc1.sub_arguments["subobject"]) == 3 + assert doc1.sub_arguments["subobject"]["arg1"].desc == "sub param" + assert doc1.sub_arguments["subobject"]["arg2"].desc == "subobj2 arg2" + assert doc1.sub_arguments["subobject"]["arg3"].desc == "arg3" diff --git a/src/psyclone/tests/utils_test.py b/src/psyclone/tests/utils_test.py index 99ff13f289..c6c339864d 100644 --- a/src/psyclone/tests/utils_test.py +++ b/src/psyclone/tests/utils_test.py @@ -99,7 +99,8 @@ def test_transformation_doc_wrapper_non_transformation(): def test_transformation_doc_wrapper_single_inheritance(): '''Test the transformation_doc_wrapper.''' - # Create a base transformation class + # Createa base transformation class + @transformation_documentation_wrapper(inherit=False) class BaseTrans(Transformation): def validate(self, node, opt1, opt2, **kwargs): @@ -116,6 +117,7 @@ def apply(self, node, opt1: bool = False, opt2=None, **kwargs): :type opt2: opt2 type. ''' + @transformation_documentation_wrapper(inherit=False) class InheritingTrans(BaseTrans): def validate(self, node, opt3, **kwargs): @@ -130,18 +132,11 @@ def apply(self, node, opt3: int = 1, **kwargs): :param opt3: opt3 docstring. ''' - assert "opt2" not in BaseTrans.validate.__doc__ - - transformation_documentation_wrapper(BaseTrans, inherit=False) - assert ":param bool opt1: opt1 docstring." in BaseTrans.validate.__doc__ assert ":param opt2: opt2 docstring." in BaseTrans.validate.__doc__ assert ":type opt2: opt2 type." in BaseTrans.validate.__doc__ - assert "opt2" not in InheritingTrans.apply.__doc__ - assert "opt3" not in InheritingTrans.validate.__doc__ - transformation_documentation_wrapper(InheritingTrans, inherit=False) - + # Test that the option worked correctly. assert (":param int opt3: opt3 docstring." in InheritingTrans.validate.__doc__) @@ -359,3 +354,129 @@ def func(temp: bool, temp2: Union[bool, int]): anno = stringify_annotation(v.annotation) # Python >= 3.14 uses the second format assert "typing.Union[bool, int]" == anno or "bool | int" == anno + + +def test_transformation_doc_wrapper_subtrans(): + '''Test the transformation doc wrapper works correctly for + subtransformations.''' + + class SubTrans1(Transformation): + + def validate(self, node, opt3, **kwargs): + ''' + Sub validate docstring + ''' + + def apply(self, node, opt3=1, **kwargs): + ''' + Sub apply docstring + + :param opt3: opt3 docstring. + :type opt3: int + ''' + + class SubTrans2(Transformation): + + def validate(self, node, opt3, **kwargs): + ''' + Sub validate docstring + ''' + + def apply(self, node, opt3: int = 1, **kwargs): + ''' + Sub apply docstring + + :param opt3: opt3 docstring. + ''' + + # Create a base transformation class + @transformation_documentation_wrapper(add_subtransformations=False) + class BaseTrans(Transformation): + _SUB_TRANSFORMATIONS = [SubTrans1, SubTrans2] + + def validate(self, node, **kwargs): + ''' + Super validate docstring + ''' + + def apply(self, node, opt1: bool = False, opt2=None, + **kwargs): + ''' + Super apply docstring + + :param opt1: opt1 docstring. + :param opt2: opt2 docstring. + :type opt2: opt2 type. + ''' + + # With add_subtransformations=False we shouldn't get any of the SubTrans + # arguments. + assert "opt3" not in BaseTrans.apply.__doc__ + + # Create a base transformation class + @transformation_documentation_wrapper() + class BaseTrans(Transformation): + _SUB_TRANSFORMATIONS = [SubTrans1, SubTrans2] + + def validate(self, node, **kwargs): + ''' + Super validate docstring + ''' + + def apply(self, node, opt1: bool = False, opt2=None, + **kwargs): + ''' + Super apply docstring + + :param opt1: opt1 docstring. + :param opt2: opt2 docstring. + :type opt2: opt2 type. + ''' + + # Disable some flake8 for this string, as empty lines in output + # contain whitespace. + correct = """Super apply docstring + + + :param opt1: opt1 docstring. + :param opt2: opt2 docstring. + :type opt2: opt2 type. + :param opt3: (Option used for SubTrans1) opt3 docstring. + :type opt3: int + :param int opt3: (Option used for SubTrans2) opt3 docstring.\ +""" # noqa: W293 + assert correct in BaseTrans.apply.__doc__ + + # Test behaviour still is consistant with inherit=False + @transformation_documentation_wrapper(inherit=False) + class BaseTrans(Transformation): + _SUB_TRANSFORMATIONS = [SubTrans1, SubTrans2] + + def validate(self, node, **kwargs): + ''' + Super validate docstring + ''' + + def apply(self, node, opt1: bool = False, opt2=None, + **kwargs): + ''' + Super apply docstring + + :param opt1: opt1 docstring. + :param opt2: opt2 docstring. + :type opt2: opt2 type. + ''' + + # Disable some flake8 for this string, as empty lines in output + # contain whitespace. + correct = """Super apply docstring + + + :param opt1: opt1 docstring. + :param opt2: opt2 docstring. + :type opt2: opt2 type. + :param opt3: (Option used for SubTrans1) opt3 docstring. + :type opt3: int + :param int opt3: (Option used for SubTrans2) opt3 docstring.\ +""" # noqa: W293 + assert correct in BaseTrans.apply.__doc__ diff --git a/src/psyclone/utils.py b/src/psyclone/utils.py index 9a7393cb02..3f94897362 100644 --- a/src/psyclone/utils.py +++ b/src/psyclone/utils.py @@ -37,6 +37,7 @@ '''This module provides generic utility functions.''' + from collections import OrderedDict import sys from psyclone.errors import InternalError @@ -88,12 +89,31 @@ def stringify_annotation(annotation) -> str: return str(annotation) -def transformation_documentation_wrapper(cls, *args, inherit=True, **kwargs): +def transformation_documentation_wrapper(*args, inherit=True, + add_subtransformations: bool = True, + **kwargs): ''' Updates the apply and validate methods' docstrings for the supplied cls, - according to the value of inherit. + according to the value of inherit. args is either a length 1 set argument + containing the class to be wrapper, or a length 0 argument set if + options are specified on the transformation_docstring_wrapper that is + handled by python. This works due to: + + >>> @transformation_documentation_wrapper(inherit=True) + >>> class myclass(): + >>> pass + + essentially being: + + >>> transformation_documentation_wrapper(inherit=True)(myclass) + + whilst the decorator without any argument is simply: + + >>> transformation_documentation_wrapper(myclass) + + We use this to vary the behaviour of the wrapper slightly depending on + whether any arguments are present. - :param Class cls: The class whose docstrings are to be updated. :param inherit: whether to inherit argument docstrings from cls' parent's apply method. If the provided argument is a list, instead the docstrings are updated from each class included in @@ -102,11 +122,13 @@ def transformation_documentation_wrapper(cls, *args, inherit=True, **kwargs): Transformation's validate docstring from its own apply docstring. :type inherit: Union[list[Class], bool] + :param add_subtransformations: Whether to add parameter docstrings from + sub transformations used by this Transformation. ''' # List of argument doctrings to never inherit. - _uninheritable_args = ["options"] + _uninheritable_args = ["options", "nodes", "node_list", "node"] - def update_func_docstring(func, added_parameters: DocstringData) -> None: + def update_func_docstring(func, added_parameters: "DocstringData") -> None: ''' Adds the docstrings specified in added_parameters to the docstring of func. @@ -125,7 +147,7 @@ def update_func_docstring(func, added_parameters: DocstringData) -> None: func_data.merge(added_parameters, replace_args=False) func.__doc__ = func_data.gen_docstring(function=func) - def wrapper(): + def wrapper(cls): ''' The wrapping function of the decorator. @@ -141,7 +163,8 @@ def wrapper(): if isinstance(inherit, list): added_parameters = DocstringData( desc=None, arguments=OrderedDict(), - raises=[], returns=None) + raises=[], returns=None, + sub_arguments=OrderedDict()) for superclass in inherit: inherited_params = \ DocstringData.create_from_object(superclass.apply) @@ -153,16 +176,42 @@ def wrapper(): ) else: added_parameters = None + if add_subtransformations and len(cls._SUB_TRANSFORMATIONS) > 0: + if added_parameters is None: + added_parameters = DocstringData( + desc=None, arguments=OrderedDict(), + raises=[], returns=None, + sub_arguments=OrderedDict()) + for trans in cls._SUB_TRANSFORMATIONS: + inherited_params = \ + DocstringData.create_from_object(trans.apply) + added_parameters.add_subarguments(trans.__name__, + inherited_params) + if added_parameters is not None: # Remove any arguments we don't want to inherit. for arg in list(added_parameters.arguments.keys()): if arg in _uninheritable_args: del added_parameters.arguments[arg] + if add_subtransformations: + for trans in cls._SUB_TRANSFORMATIONS: + for arg in list( + added_parameters.sub_arguments[ + trans.__name__ + ].keys() + ): + if arg in _uninheritable_args: + del added_parameters.sub_arguments[ + trans.__name__][arg] update_func_docstring(cls.apply, added_parameters) + # Update the validate docstring added_parameters = DocstringData.create_from_object(cls.apply) if added_parameters is not None: update_func_docstring(cls.validate, added_parameters) return cls - return wrapper(*args, **kwargs) + if len(args) > 0: + return wrapper(*args) + else: + return wrapper