Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Safe-DS stubs also contain docstring information. #78

Merged
merged 20 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ __pycache__/

# Python environment
venv/
.venv/

# Pytest outputs
.mypy_cache/
Expand Down
134 changes: 120 additions & 14 deletions src/safeds_stubgen/stubs_generator/_generate_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
if TYPE_CHECKING:
from collections.abc import Generator

from safeds_stubgen.docstring_parsing import AttributeDocstring, ClassDocstring, FunctionDocstring


class NamingConvention(IntEnum):
PYTHON = 1
Expand Down Expand Up @@ -65,7 +67,11 @@ def _generate_stubs_data(
# the file would look like this: "package path.to.myPackage\n" or this:
# '@PythonModule("path.to.my_package")\npackage path.to.myPackage\n'. With the split we check if the module
# has enough information, if not, we won't create it.
splitted_text = module_text.split("\n")
_module_text = module_text
if _module_text.startswith("/**"):
# Remove docstring
_module_text = "*/\n".join(_module_text.split("*/\n\n")[1:])
splitted_text = _module_text.split("\n")
if len(splitted_text) <= 2 or (len(splitted_text) == 3 and splitted_text[1].startswith("package ")):
continue

Expand Down Expand Up @@ -172,9 +178,9 @@ def __call__(self, module: Module) -> str:
self._current_todo_msgs: set[str] = set()
self.module = module
self.class_generics: list = []
return self._create_module_string(module)
return self._create_module_string()

def _create_module_string(self, module: Module) -> str:
def _create_module_string(self) -> str:
# Create package info
package_info = self._get_shortest_public_reexport()
package_info_camel_case = _convert_name_to_convention(package_info, self.naming_convention)
Expand All @@ -184,24 +190,29 @@ def _create_module_string(self, module: Module) -> str:
module_name_info = f'@PythonModule("{package_info}")\n'
module_header = f"{module_name_info}package {package_info_camel_case}\n"

# Create docstring
docstring = self._create_sds_docstring_description(self.module.docstring, "")
if docstring:
docstring += "\n"

# Create global functions and properties
for function in module.global_functions:
for function in self.module.global_functions:
if function.is_public:
module_text += f"\n{self._create_function_string(function, is_method=False)}\n"

# Create classes, class attr. & class methods
for class_ in module.classes:
for class_ in self.module.classes:
if class_.is_public and not class_.inherits_from_exception:
module_text += f"\n{self._create_class_string(class_)}\n"

# Create enums & enum instances
for enum in module.enums:
for enum in self.module.enums:
module_text += f"\n{self._create_enum_string(enum)}\n"

# Create imports - We have to create them last, since we have to check all used types in this module first
module_header += self._create_imports_string()

return module_header + module_text
return docstring + module_header + module_text

def _create_imports_string(self) -> str:
if not self.module_imports:
Expand Down Expand Up @@ -230,7 +241,6 @@ def _create_imports_string(self) -> str:

def _create_class_string(self, class_: Class, class_indentation: str = "") -> str:
inner_indentations = class_indentation + "\t"
class_text = ""

# Constructor parameter
if class_.is_abstract:
Expand Down Expand Up @@ -324,7 +334,7 @@ def _create_class_string(self, class_: Class, class_indentation: str = "") -> st
)

# Attributes
class_text += self._create_class_attribute_string(class_.attributes, inner_indentations)
class_text = self._create_class_attribute_string(class_.attributes, inner_indentations)

# Inner classes
for inner_class in class_.classes:
Expand All @@ -336,14 +346,17 @@ def _create_class_string(self, class_: Class, class_indentation: str = "") -> st
# Methods
class_text += self._create_class_method_string(class_.methods, inner_indentations)

# If the does not have a body, we just return the signature line
# Docstring
docstring = self._create_sds_docstring(class_.docstring, "", node=class_)

# If the does not have a body, we just return the docstring and signature line
if not class_text:
return class_signature
return docstring + class_signature

# Close class
class_text += f"{class_indentation}}}"

return f"{class_signature} {{{class_text}"
return f"{docstring}{class_signature} {{{class_text}"

def _create_class_method_string(
self,
Expand Down Expand Up @@ -409,10 +422,13 @@ def _create_class_attribute_string(self, attributes: list[Attribute], inner_inde
if not type_string:
self._current_todo_msgs.add("attr without type")

# Create docstring text
docstring = self._create_sds_docstring(attribute.docstring, inner_indentations)

# Create attribute string
class_attributes.append(
f"{self._create_todo_msg(inner_indentations)}"
f"{inner_indentations}{attr_name_annotation}"
f"{docstring}{inner_indentations}{attr_name_annotation}"
f"{static_string}attr {attr_name_camel_case}"
f"{type_string}",
)
Expand Down Expand Up @@ -461,6 +477,9 @@ def _create_function_string(self, function: Function, indentations: str = "", is
type_var_string = ", ".join(type_var_names)
type_var_info = f"<{type_var_string}>"

# Docstring
docstring = self._create_sds_docstring(function.docstring, indentations, function)

# Convert function name to camelCase
name = function.name
camel_case_name = _convert_name_to_convention(name, self.naming_convention)
Expand All @@ -476,6 +495,7 @@ def _create_function_string(self, function: Function, indentations: str = "", is
# Create string and return
return (
f"{self._create_todo_msg(indentations)}"
f"{docstring}"
f"{indentations}@Pure\n"
f"{function_name_annotation}"
f"{indentations}{static}fun {camel_case_name}{type_var_info}"
Expand All @@ -496,6 +516,9 @@ def _create_property_function_string(self, function: Function, indentations: str
# Escape keywords
camel_case_name = _replace_if_safeds_keyword(camel_case_name)

# Docstring
docstring = self._create_sds_docstring_description(function.docstring.description, indentations)

# Create type information
result_types = [result.type for result in function.results if result.type is not None]
result_union = UnionType(types=result_types)
Expand All @@ -505,6 +528,7 @@ def _create_property_function_string(self, function: Function, indentations: str

return (
f"{self._create_todo_msg(indentations)}"
f"{docstring}"
f"{indentations}{function_name_annotation}"
f"attr {camel_case_name}{type_string}"
)
Expand Down Expand Up @@ -617,8 +641,11 @@ def _create_parameter_string(
return ""

def _create_enum_string(self, enum_data: Enum) -> str:
# Docstring
docstring = self._create_sds_docstring(enum_data.docstring, "")

# Signature
enum_signature = f"enum {enum_data.name}"
enum_signature = f"{docstring}enum {enum_data.name}"

# Enum body
enum_text = ""
Expand Down Expand Up @@ -917,6 +944,85 @@ def _module_name_check(name: str, string: str) -> bool:
return module_qname
return ".".join(shortest_id)

@staticmethod
def _create_sds_docstring_description(description: str, indentations: str) -> str:
if not description:
return ""

description = description.rstrip("\n")
description = description.lstrip("\n")
description = description.replace("\n", f"\n{indentations} * ")
return f"{indentations}/**\n{indentations} * {description}\n{indentations} */\n"

def _create_sds_docstring(
self,
docstring: ClassDocstring | FunctionDocstring | AttributeDocstring,
indentations: str,
node: Class | Function | None = None,
) -> str:
full_docstring = ""

# Description
if docstring.description:
docstring_description = docstring.description.rstrip("\n")
docstring_description = docstring_description.lstrip("\n")
docstring_description = docstring_description.replace("\n", f"\n{indentations} * ")
full_docstring += f"{indentations} * {docstring_description}\n"

# Parameters
full_parameter_docstring = ""
if node is not None:
parameters = []
if isinstance(node, Class):
if node.constructor is not None:
parameters = node.constructor.parameters
else:
parameters = node.parameters

if parameters:
parameter_docstrings = []
for parameter in parameters:
param_desc = parameter.docstring.description
if not param_desc:
continue

param_desc = f"\n{indentations} * ".join(param_desc.split("\n"))

parameter_name = _convert_name_to_convention(parameter.name, self.naming_convention)
parameter_docstrings.append(f"{indentations} * @param {parameter_name} {param_desc}\n")

full_parameter_docstring = "".join(parameter_docstrings)

if full_parameter_docstring and full_docstring:
full_parameter_docstring = f"{indentations} *\n{full_parameter_docstring}"
full_docstring += full_parameter_docstring

# Results
full_result_docstring = ""
if isinstance(node, Function):
result_docstrings = []
for result in node.results:
result_desc = result.docstring.description
if not result_desc:
continue

result_desc = f"\n{indentations} * ".join(result_desc.split("\n"))

result_name = _convert_name_to_convention(result.name, self.naming_convention)
result_docstrings.append(f"{indentations} * @result {result_name} {result_desc}\n")

full_result_docstring = "".join(result_docstrings)

if full_result_docstring and full_docstring:
full_result_docstring = f"{indentations} *\n{full_result_docstring}"
full_docstring += full_result_docstring

# Open and close the docstring
if full_docstring:
full_docstring = f"{indentations}/**\n{full_docstring}{indentations} */\n"

return full_docstring


def _callable_type_name_generator() -> Generator:
"""Generate a name for callable type parameters starting from 'a' until 'zz'."""
Expand Down
4 changes: 3 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from typing import Any

import pytest
Expand All @@ -10,7 +11,8 @@ class SdsStubExtension(SingleFileSnapshotExtension):
_file_extension = "sdsstub"

def serialize(self, data: str, **_kwargs: Any) -> SerializedData:
return bytes(data, encoding="utf8")
normalized_data = re.sub(r"\r?\n", "\n", data)
return bytes(normalized_data, encoding="utf8")


@pytest.fixture()
Expand Down
61 changes: 54 additions & 7 deletions tests/data/docstring_parser_package/epydoc.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
"""
Test module for docstring tests.

A module for testing the various docstring types.
"""
from enum import Enum


class ClassWithDocumentation:
"""
Lorem ipsum. Code::
Expand Down Expand Up @@ -36,7 +44,7 @@ class ClassWithParameters:
@type p: int
"""

def __init__(self) -> None:
def __init__(self, p) -> None:
pass


Expand All @@ -48,7 +56,11 @@ class ClassWithAttributes:

@ivar p: foo defaults to 1
@type p: int
@ivar q: foo defaults to 1
@type q: int
"""
p: int
q = 1

def __init__(self) -> None:
pass
Expand All @@ -61,20 +73,21 @@ class ClassWithAttributesNoType:
Dolor sit amet.

@ivar p: foo defaults to 1
@ivar q: foo defaults to 1
"""
p: int
q = 1

def __init__(self) -> None:
pass


def function_with_parameters() -> None:
def function_with_parameters(no_type_no_default, type_no_default, with_default, *args, **kwargs) -> None:
"""
Lorem ipsum.

Dolor sit amet.

Parameters
----------
@param no_type_no_default: no type and no default
@param type_no_default: type but no default
@type type_no_default: int
Expand All @@ -83,14 +96,14 @@ def function_with_parameters() -> None:
"""


def function_with_result_value_and_type() -> None:
def function_with_result_value_and_type() -> bool:
"""
Lorem ipsum.

Dolor sit amet.

@return: return value
@rtype: float
@rtype: bool
"""


Expand All @@ -104,7 +117,41 @@ def function_with_result_value_no_type() -> None:
"""


def function_without_result_value() -> None:
def function_without_result_value():
"""
Lorem ipsum.

Dolor sit amet.
"""


class ClassWithMethod:
def method_with_docstring(self, a) -> bool:
"""
Lorem ipsum.

Dolor sit amet.

@param a: type but no default
@type a: int

@return: return value
@rtype: bool
"""

@property
def property_method_with_docstring(self) -> bool:
"""
Lorem ipsum.

Dolor sit amet.

@return: return value
@rtype: bool
"""


class EnumDocstring(Enum):
"""
Lorem ipsum.

Expand Down
Loading