diff --git a/docs/README.md b/docs/README.md index b71c3af3..5cb4c4f4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ [![codecov](https://codecov.io/gh/Safe-DS/Stub-Generator/branch/main/graph/badge.svg?token=UyCUY59HKM)](https://codecov.io/gh/Safe-DS/Stub-Generator) [![Documentation Status](https://readthedocs.org/projects/safe-ds-stub-generator/badge/?version=stable)](https://stubgen.safeds.com) -Automated generation of [Safe-DS stubs](https://dsl.safeds.com/en/stable/language/stub-language/) for Python libraries. +Automated generation of [Safe-DS stubs](https://dsl.safeds.com/en/stable/stub-language/) for Python libraries. ## Installation @@ -29,7 +29,7 @@ options: -v, --verbose show info messages -p PACKAGE, --package PACKAGE The name of the package. - -s SRC, --src SRC Directory containing the Python code of the package. If this is omitted, we try to locate the package with the given name in the current Python interpreter. + -s SRC, --src SRC Source directory containing the Python code of the package. -o OUT, --out OUT Output directory. --docstyle {PLAINTEXT,EPYDOC,GOOGLE,NUMPYDOC,REST} The docstring style. diff --git a/src/safeds_stubgen/api_analyzer/__init__.py b/src/safeds_stubgen/api_analyzer/__init__.py index 8c5ea018..ac7e9894 100644 --- a/src/safeds_stubgen/api_analyzer/__init__.py +++ b/src/safeds_stubgen/api_analyzer/__init__.py @@ -19,7 +19,7 @@ from ._ast_visitor import result_name_generator from ._get_api import get_api from ._mypy_helpers import get_classdef_definitions, get_funcdef_definitions, get_mypyfile_definitions -from ._package_metadata import distribution, distribution_version, package_root +from ._package_metadata import distribution, distribution_version from ._types import ( AbstractType, BoundaryType, @@ -34,6 +34,7 @@ TupleType, TypeVarType, UnionType, + UnknownType, ) __all__ = [ @@ -58,7 +59,6 @@ "LiteralType", "Module", "NamedType", - "package_root", "Parameter", "ParameterAssignment", "QualifiedImport", @@ -68,6 +68,7 @@ "TupleType", "TypeVarType", "UnionType", + "UnknownType", "VarianceKind", "WildcardImport", ] diff --git a/src/safeds_stubgen/api_analyzer/_ast_visitor.py b/src/safeds_stubgen/api_analyzer/_ast_visitor.py index 8b2add1c..066a061d 100644 --- a/src/safeds_stubgen/api_analyzer/_ast_visitor.py +++ b/src/safeds_stubgen/api_analyzer/_ast_visitor.py @@ -938,6 +938,10 @@ def mypy_type_to_abstract_type( # from the import information missing_import_name = mypy_type.missing_import_name.split(".")[-1] # type: ignore[union-attr] name, qname = self._find_alias(missing_import_name) + + if not qname: # pragma: no cover + return sds_types.UnknownType() + return sds_types.NamedType(name=name, qname=qname) else: return sds_types.NamedType(name="Any", qname="typing.Any") @@ -971,6 +975,10 @@ def mypy_type_to_abstract_type( # if not, we check if it's an alias name, qname = self._find_alias(mypy_type.name) + + if not qname: # pragma: no cover + return sds_types.UnknownType() + return sds_types.NamedType(name=name, qname=qname) # Builtins @@ -1001,7 +1009,8 @@ def mypy_type_to_abstract_type( ) else: return sds_types.NamedType(name=type_name, qname=mypy_type.type.fullname) - raise ValueError("Unexpected type.") # pragma: no cover + + return sds_types.UnknownType() # pragma: no cover def _find_alias(self, type_name: str) -> tuple[str, str]: module = self.__declaration_stack[0] @@ -1010,27 +1019,17 @@ def _find_alias(self, type_name: str) -> tuple[str, str]: if not isinstance(module, Module): # pragma: no cover raise TypeError(f"Expected module, got {type(module)}.") - name = "" - qname = "" - qualified_imports = module.qualified_imports - import_aliases = [qimport.alias for qimport in qualified_imports] + # First we check if it can be found in the imports + name, qname = self._search_alias_in_qualified_imports(module.qualified_imports, type_name) + if name and qname: + return name, qname if type_name in self.aliases: qnames: set = self.aliases[type_name] if len(qnames) == 1: - # We have to check if this is an alias from an import - import_name, import_qname = self._search_alias_in_qualified_imports(qualified_imports, type_name) - # We need a deepcopy since qnames is a pointer to the set in the alias dict - qname = import_qname if import_qname else deepcopy(qnames).pop() - name = import_name if import_name else qname.split(".")[-1] - elif type_name in import_aliases: - # We check if the type was imported - qimport_name, qimport_qname = self._search_alias_in_qualified_imports(qualified_imports, type_name) - - if qimport_qname: - qname = qimport_qname - name = qimport_name + qname = deepcopy(qnames).pop() + name = qname.split(".")[-1] else: # In this case some types where defined in multiple modules with the same names. for alias_qname in qnames: @@ -1041,14 +1040,9 @@ def _find_alias(self, type_name: str) -> tuple[str, str]: if self.mypy_file is None: # pragma: no cover raise TypeError("Expected mypy_file (module information), got None.") - if type_path == self.mypy_file.fullname: + if self.mypy_file.fullname in type_path: qname = alias_qname break - else: - name, qname = self._search_alias_in_qualified_imports(qualified_imports, type_name) - - if not qname: # pragma: no cover - raise ValueError(f"It was not possible to find out where the alias {type_name} was defined.") return name, qname diff --git a/src/safeds_stubgen/api_analyzer/_ast_walker.py b/src/safeds_stubgen/api_analyzer/_ast_walker.py index 9568b826..7b86b596 100644 --- a/src/safeds_stubgen/api_analyzer/_ast_walker.py +++ b/src/safeds_stubgen/api_analyzer/_ast_walker.py @@ -41,37 +41,28 @@ def __walk(self, node: MypyFile | ClassDef | Decorator | FuncDef | AssignmentStm self.__enter(node) - definitions: list = [] + # Search nodes for more child nodes. Skip other not specified types, since we either get them through the + # ast_visitor, some other way or don't need to parse them at all + child_nodes = [] if isinstance(node, MypyFile): definitions = get_mypyfile_definitions(node) + child_nodes = [ + _def for _def in definitions if _def.__class__.__name__ in {"FuncDef", "ClassDef", "Decorator"} + ] elif isinstance(node, ClassDef): definitions = get_classdef_definitions(node) - elif isinstance(node, FuncDef): + child_nodes = [ + _def + for _def in definitions + if _def.__class__.__name__ in {"AssignmentStmt", "FuncDef", "ClassDef", "Decorator"} + ] + elif isinstance(node, FuncDef) and node.name == "__init__": definitions = get_funcdef_definitions(node) - - # Skip other types, since we either get them through the ast_visitor, some other way or - # don't need to parse them - child_nodes = [ - _def - for _def in definitions - if _def.__class__.__name__ - in { - "AssignmentStmt", - "FuncDef", - "ClassDef", - "Decorator", - } - ] + child_nodes = [_def for _def in definitions if _def.__class__.__name__ == "AssignmentStmt"] for child_node in child_nodes: - # Ignore global variables and function attributes if the function is an __init__ - if isinstance(child_node, AssignmentStmt): - if isinstance(node, MypyFile): - continue - if isinstance(node, FuncDef) and node.name != "__init__": - continue - - if isinstance(child_node, FuncDef) and isinstance(node, FuncDef): + # The '__mypy-replace' name is a mypy placeholer which we don't want to parse. + if getattr(child_node, "name", "") == "__mypy-replace": # pragma: no cover continue self.__walk(child_node, visited_nodes) diff --git a/src/safeds_stubgen/api_analyzer/_get_api.py b/src/safeds_stubgen/api_analyzer/_get_api.py index 13ead372..b6127668 100644 --- a/src/safeds_stubgen/api_analyzer/_get_api.py +++ b/src/safeds_stubgen/api_analyzer/_get_api.py @@ -14,33 +14,30 @@ from ._api import API from ._ast_visitor import MyPyAstVisitor from ._ast_walker import ASTWalker -from ._package_metadata import distribution, distribution_version, package_root +from ._package_metadata import distribution, distribution_version if TYPE_CHECKING: from pathlib import Path def get_api( - package_name: str, - root: Path | None = None, + root: Path, docstring_style: DocstringStyle = DocstringStyle.PLAINTEXT, is_test_run: bool = False, ) -> API: - # Check root - if root is None: - root = package_root(package_name) + init_roots = _get_nearest_init_dirs(root) + if len(init_roots) == 1: + root = init_roots[0] + + logging.info("Started gathering the raw package data with Mypy.") walkable_files = [] package_paths = [] for file_path in root.glob(pattern="./**/*.py"): - logging.info( - "Working on file {posix_path}", - extra={"posix_path": str(file_path)}, - ) - # Check if the current path is a test directory - if not is_test_run and ("test" in file_path.parts or "tests" in file_path.parts): - logging.info("Skipping test file") + if not is_test_run and ("test" in file_path.parts or "tests" in file_path.parts or "docs" in file_path.parts): + log_msg = f"Skipping test file in {file_path}" + logging.info(log_msg) continue # Check if the current file is an init file @@ -56,6 +53,9 @@ def get_api( if not walkable_files: raise ValueError("No files found to analyse.") + # Package name + package_name = root.stem + # Get distribution data dist = distribution(package_name=package_name) or "" dist_version = distribution_version(dist=dist) or "" @@ -77,6 +77,25 @@ def get_api( return callable_visitor.api +def _get_nearest_init_dirs(root: Path) -> list[Path]: + all_inits = list(root.glob("./**/__init__.py")) + shortest_init_paths = [] + shortest_len = -1 + for init in all_inits: + path_len = len(init.parts) + if shortest_len == -1: + shortest_len = path_len + shortest_init_paths.append(init.parent) + elif path_len <= shortest_len: # pragma: no cover + if path_len == shortest_len: + shortest_init_paths.append(init.parent) + else: + shortest_len = path_len + shortest_init_paths = [init.parent] + + return shortest_init_paths + + def _get_mypy_build(files: list[str]) -> mypy_build.BuildResult: """Build a mypy checker and return the build result.""" mypyfiles, opt = mypy_main.process_options(files) diff --git a/src/safeds_stubgen/api_analyzer/_package_metadata.py b/src/safeds_stubgen/api_analyzer/_package_metadata.py index 63a62d26..a7d1215a 100644 --- a/src/safeds_stubgen/api_analyzer/_package_metadata.py +++ b/src/safeds_stubgen/api_analyzer/_package_metadata.py @@ -1,15 +1,6 @@ from __future__ import annotations -import importlib from importlib.metadata import packages_distributions, version -from pathlib import Path - - -def package_root(package_name: str) -> Path: - path_as_string = importlib.import_module(package_name).__file__ - if path_as_string is None: - raise AssertionError(f"Cannot find package root for '{path_as_string}'.") - return Path(path_as_string).parent def distribution(package_name: str) -> str | None: diff --git a/src/safeds_stubgen/api_analyzer/_types.py b/src/safeds_stubgen/api_analyzer/_types.py index 003331c5..b1a85511 100644 --- a/src/safeds_stubgen/api_analyzer/_types.py +++ b/src/safeds_stubgen/api_analyzer/_types.py @@ -14,6 +14,8 @@ class AbstractType(metaclass=ABCMeta): @classmethod def from_dict(cls, d: dict[str, Any]) -> AbstractType: match d["kind"]: + case UnknownType.__name__: + return UnknownType.from_dict(d) case NamedType.__name__: return NamedType.from_dict(d) case EnumType.__name__: @@ -45,6 +47,21 @@ def from_dict(cls, d: dict[str, Any]) -> AbstractType: def to_dict(self) -> dict[str, Any]: ... +@dataclass(frozen=True) +class UnknownType(AbstractType): + @classmethod + def from_dict(cls, _: dict[str, Any]) -> UnknownType: + return UnknownType() + + def to_dict(self) -> dict[str, str]: + return {"kind": self.__class__.__name__} + + def __eq__(self, other: object) -> bool: + if not isinstance(other, UnknownType): # pragma: no cover + return NotImplemented + return True + + @dataclass(frozen=True) class NamedType(AbstractType): name: str @@ -269,6 +286,11 @@ def to_dict(self) -> dict[str, Any]: return {"kind": self.__class__.__name__, "types": type_list} + def __eq__(self, other: object) -> bool: + if not isinstance(other, ListType): # pragma: no cover + return NotImplemented + return Counter(self.types) == Counter(other.types) + def __hash__(self) -> int: return hash(frozenset(self.types)) @@ -315,6 +337,11 @@ def to_dict(self) -> dict[str, Any]: "return_type": self.return_type.to_dict(), } + def __eq__(self, other: object) -> bool: + if not isinstance(other, CallableType): # pragma: no cover + return NotImplemented + return Counter(self.parameter_types) == Counter(other.parameter_types) and self.return_type == other.return_type + def __hash__(self) -> int: return hash(frozenset([*self.parameter_types, self.return_type])) @@ -338,6 +365,11 @@ def to_dict(self) -> dict[str, Any]: "types": [t.to_dict() for t in self.types], } + def __eq__(self, other: object) -> bool: + if not isinstance(other, SetType): # pragma: no cover + return NotImplemented + return Counter(self.types) == Counter(other.types) + def __hash__(self) -> int: return hash(frozenset(self.types)) @@ -353,6 +385,11 @@ def from_dict(cls, d: dict[str, Any]) -> LiteralType: def to_dict(self) -> dict[str, Any]: return {"kind": self.__class__.__name__, "literals": self.literals} + def __eq__(self, other: object) -> bool: + if not isinstance(other, LiteralType): # pragma: no cover + return NotImplemented + return Counter(self.literals) == Counter(other.literals) + def __hash__(self) -> int: return hash(frozenset(self.literals)) @@ -390,6 +427,11 @@ def to_dict(self) -> dict[str, Any]: return {"kind": self.__class__.__name__, "types": type_list} + def __eq__(self, other: object) -> bool: + if not isinstance(other, TupleType): # pragma: no cover + return NotImplemented + return Counter(self.types) == Counter(other.types) + def __hash__(self) -> int: return hash(frozenset(self.types)) diff --git a/src/safeds_stubgen/api_analyzer/cli/_cli.py b/src/safeds_stubgen/api_analyzer/cli/_cli.py index c20c3833..c68df4d6 100644 --- a/src/safeds_stubgen/api_analyzer/cli/_cli.py +++ b/src/safeds_stubgen/api_analyzer/cli/_cli.py @@ -17,7 +17,7 @@ def cli() -> None: if args.verbose: logging.basicConfig(level=logging.INFO) - _run_api_command(args.package, args.src, args.out, args.docstyle, args.testrun, args.naming_convert) + _run_api_command(args.src, args.out, args.docstyle, args.testrun, args.naming_convert) def _get_args() -> argparse.Namespace: @@ -26,22 +26,12 @@ def _get_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Analyze Python code.") parser.add_argument("-v", "--verbose", help="show info messages", action="store_true") - parser.add_argument( - "-p", - "--package", - help="The name of the package.", - type=str, - required=True, - ) parser.add_argument( "-s", "--src", - help=( - "Directory containing the Python code of the package. If this is omitted, we try to locate the package " - "with the given name in the current Python interpreter." - ), + help="Source directory containing the Python code of the package.", type=Path, - required=False, + required=True, default=None, ) parser.add_argument("-o", "--out", help="Output directory.", type=Path, required=True) @@ -75,7 +65,6 @@ def _get_args() -> argparse.Namespace: def _run_api_command( - package: str, src_dir_path: Path, out_dir_path: Path, docstring_style: DocstringStyle, @@ -87,8 +76,6 @@ def _run_api_command( Parameters ---------- - package : str - The name of the package. out_dir_path : Path The path to the output directory. docstring_style : DocstringStyle @@ -96,8 +83,8 @@ def _run_api_command( is_test_run : bool Set True if files in test directories should be parsed too. """ - api = get_api(package, src_dir_path, docstring_style, is_test_run) - out_file_api = out_dir_path.joinpath(f"{package}__api.json") + api = get_api(src_dir_path, docstring_style, is_test_run) + out_file_api = out_dir_path.joinpath(f"{src_dir_path.stem}__api.json") api.to_json_file(out_file_api) generate_stubs(api, out_dir_path, convert_identifiers) diff --git a/src/safeds_stubgen/docstring_parsing/_docstring_parser.py b/src/safeds_stubgen/docstring_parsing/_docstring_parser.py index e73ce7d8..21d9b552 100644 --- a/src/safeds_stubgen/docstring_parsing/_docstring_parser.py +++ b/src/safeds_stubgen/docstring_parsing/_docstring_parser.py @@ -7,7 +7,7 @@ from griffe.docstrings.dataclasses import DocstringAttribute, DocstringParameter from griffe.docstrings.utils import parse_annotation from griffe.enumerations import DocstringSectionKind, Parser -from griffe.expressions import Expr, ExprBinOp, ExprName, ExprSubscript, ExprTuple +from griffe.expressions import Expr, ExprAttribute, ExprBinOp, ExprBoolOp, ExprList, ExprName, ExprSubscript, ExprTuple # noinspection PyProtectedMember import safeds_stubgen.api_analyzer._types as sds_types @@ -27,8 +27,6 @@ from griffe.dataclasses import Object from mypy import nodes - from safeds_stubgen.api_analyzer import AbstractType - class DocstringParser(AbstractDocstringParser): def __init__(self, parser: Parser, package_path: Path): @@ -131,9 +129,13 @@ def get_parameter_documentation( else: type_ = self._griffe_annotation_to_api_type(annotation, griffe_docstring) + default_value = "" + if last_parameter.default: + default_value = str(last_parameter.default) + return ParameterDocstring( type=type_, - default_value=last_parameter.default or "", + default_value=default_value, description=last_parameter.description.strip("\n") or "", ) @@ -248,8 +250,12 @@ def _get_matching_docstrings( return [] - def _griffe_annotation_to_api_type(self, annotation: Expr | str, docstring: Docstring) -> AbstractType: - if isinstance(annotation, ExprName): + def _griffe_annotation_to_api_type( + self, + annotation: Expr | str, + docstring: Docstring, + ) -> sds_types.AbstractType | None: + if isinstance(annotation, ExprName | ExprAttribute): if annotation.canonical_path == "typing.Any": return sds_types.NamedType(name="Any", qname="typing.Any") elif annotation.canonical_path == "int": @@ -266,70 +272,95 @@ def _griffe_annotation_to_api_type(self, annotation: Expr | str, docstring: Docs return sds_types.TupleType(types=[]) elif annotation.canonical_path == "set": return sds_types.SetType(types=[]) - else: - return sds_types.NamedType(name=annotation.canonical_name, qname=annotation.canonical_path) + return sds_types.NamedType(name=annotation.canonical_name, qname=annotation.canonical_path) elif isinstance(annotation, ExprSubscript): + any_type = sds_types.NamedType(name="Any", qname="typing.Any") slices = annotation.slice + types: list[sds_types.AbstractType] = [] if isinstance(slices, ExprTuple): - types = [] for slice_ in slices.elements: new_type = self._griffe_annotation_to_api_type(slice_, docstring) - types.append(new_type) + if new_type is not None: + types.append(new_type) else: - types = [] type_ = self._griffe_annotation_to_api_type(slices, docstring) if type_ is not None: types.append(type_) - if annotation.canonical_path == "list": + if annotation.canonical_path in {"list", "collections.abc.Sequence", "collections.abc.Iterator"}: return sds_types.ListType(types=types) elif annotation.canonical_path == "tuple": return sds_types.TupleType(types=types) elif annotation.canonical_path == "set": return sds_types.SetType(types=types) + elif annotation.canonical_path in {"collections.abc.Callable", "typing.Callable"}: + param_type = types[0] if len(types) >= 1 else [any_type] + if not isinstance(param_type, sds_types.AbstractType): # pragma: no cover + raise TypeError(f"Expected AbstractType object, received {type(param_type)}") + parameter_types = param_type.types if isinstance(param_type, sds_types.ListType) else [param_type] + return_type = types[1] if len(types) >= 2 else any_type + return sds_types.CallableType(parameter_types=parameter_types, return_type=return_type) + elif annotation.canonical_path in {"dict", "collections.abc.Mapping", "typing.Mapping"}: + key_type = types[0] if len(types) >= 1 else any_type + value_type = types[1] if len(types) >= 2 else any_type + return sds_types.DictType(key_type=key_type, value_type=value_type) elif annotation.canonical_path == "typing.Optional": types.append(sds_types.NamedType(name="None", qname="builtins.None")) return sds_types.UnionType(types=types) else: # pragma: no cover raise TypeError(f"Can't parse unexpected type from docstring {annotation.canonical_path}.") + elif isinstance(annotation, ExprList): + elements = [] + for element in annotation.elements: + annotation_element = self._griffe_annotation_to_api_type(element, docstring) + if annotation_element is not None: + elements.append(annotation_element) + return sds_types.ListType(types=elements) + elif isinstance(annotation, ExprBoolOp): + types = [] + for value in annotation.values: + value_type_ = self._griffe_annotation_to_api_type(value, docstring) + if value_type_ is not None: + types.append(value_type_) + return sds_types.UnionType(types=types) elif isinstance(annotation, ExprTuple): elements = [] # Todo Remove the "optional" related part of the code once issue #99 is solved. has_optional = False - for element in annotation.elements: - if not isinstance(element, str) and element.canonical_path == "optional": + for element_ in annotation.elements: + if not isinstance(element_, str) and element_.canonical_path == "optional": has_optional = True else: - new_element = self._griffe_annotation_to_api_type(element, docstring) - if new_element is None: # pragma: no cover - continue - elements.append(new_element) + new_element = self._griffe_annotation_to_api_type(element_, docstring) + if new_element is not None: + elements.append(new_element) if has_optional: elements.append(sds_types.NamedType(name="None", qname="builtins.None")) return sds_types.UnionType(elements) - else: - return sds_types.TupleType(elements) + return sds_types.TupleType(elements) elif isinstance(annotation, str): new_annotation = self._remove_default_from_griffe_annotation(annotation) parsed_annotation = parse_annotation(new_annotation, docstring) if parsed_annotation in (new_annotation, annotation): if parsed_annotation == "None": return sds_types.NamedType(name="None", qname="builtins.None") - else: # pragma: no cover - raise TypeError( - f"Can't parse unexpected type from docstring {parsed_annotation}. We received a " - f"str type, but we expected a Griffe object.", - ) + else: + return None else: return self._griffe_annotation_to_api_type(parsed_annotation, docstring) elif isinstance(annotation, ExprBinOp): - types = [self._griffe_annotation_to_api_type(annotation.right, docstring)] + type_ = self._griffe_annotation_to_api_type(annotation.right, docstring) + types = [type_] if type_ is not None else [] left_bin = annotation.left if isinstance(left_bin, ExprBinOp): while isinstance(left_bin, ExprBinOp): - types.append(self._griffe_annotation_to_api_type(left_bin.right, docstring)) + right_type = self._griffe_annotation_to_api_type(left_bin.right, docstring) + if right_type is not None: # pragma: no cover + types.append(right_type) left_bin = left_bin.left - types.append(self._griffe_annotation_to_api_type(left_bin, docstring)) + left_type = self._griffe_annotation_to_api_type(left_bin, docstring) + if left_type is not None: + types.append(left_type) return sds_types.UnionType(types=types) else: # pragma: no cover raise TypeError( diff --git a/src/safeds_stubgen/stubs_generator/_generate_stubs.py b/src/safeds_stubgen/stubs_generator/_generate_stubs.py index 15aba54a..39b4f081 100644 --- a/src/safeds_stubgen/stubs_generator/_generate_stubs.py +++ b/src/safeds_stubgen/stubs_generator/_generate_stubs.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from enum import IntEnum from pathlib import Path from types import NoneType @@ -65,6 +66,9 @@ def _generate_stubs_data( if module.name == "__init__": continue + log_msg = f"Creating stub data for {module.id}" + logging.info(log_msg) + module_text = stubs_generator(module) # Each text block we create ends with "\n", therefore, if there is only the package information @@ -91,6 +95,9 @@ def _generate_stubs_files( naming_convention: NamingConvention, ) -> None: for module_dir, module_name, module_text in stubs_data: + log_msg = f"Creating stub file for {module_dir}" + logging.info(log_msg) + # Create module dir module_dir.mkdir(parents=True, exist_ok=True) @@ -748,6 +755,9 @@ def _create_type_string(self, type_data: dict | None) -> str: self._current_todo_msgs.add(name) return f"{name}<{', '.join(types)}>" return f"{name}" + elif kind == "UnknownType": # pragma: no cover + self._current_todo_msgs.add("unknown") + return "unknown" elif kind == "UnionType": # In Mypy LiteralTypes are getting seperated into unions of LiteralTypes, # and we have to join them for the stubs. @@ -1004,6 +1014,7 @@ def _create_todo_msg(self, indentations: str) -> str: "attr without type": "Attribute has no type information.", "result without type": "Result type information missing.", "internal class as type": "An internal class must not be used as a type in a public class.", + "unknown": "Unknown type - Type could not be parsed.", }[msg] for msg in self._current_todo_msgs ] diff --git a/tests/data/docstring_parser_package/googledoc.py b/tests/data/docstring_parser_package/googledoc.py index be055c4b..1fa5d3cd 100644 --- a/tests/data/docstring_parser_package/googledoc.py +++ b/tests/data/docstring_parser_package/googledoc.py @@ -3,7 +3,7 @@ A module for testing the various docstring types. """ from enum import Enum -from typing import Optional, Any +from typing import Optional, Any, Callable, Mapping from tests.data.various_modules_package.another_path.another_module import AnotherClass @@ -230,6 +230,10 @@ class ClassWithVariousAttributeTypes: optional_type_2 (Optional[int]): class_type (ClassWithAttributes): imported_type (AnotherClass): + callable_type (Callable[[int], str]): + mapping_type (Mapping[int, str]): + bool_op_type (int or str or bool): + list_type_5 ([int]): """ no_type = "" optional_type = "" @@ -255,6 +259,21 @@ class ClassWithVariousAttributeTypes: optional_type_2: Optional[int] class_type: ClassWithAttributes imported_type: AnotherClass + callable_type: Callable[[int], str] + mapping_type: Mapping[int, str] + bool_op_type: int | str | bool + list_type_5: list[int] + + +def uninferable_return_doc(): + """ + uninferable_return_doc. + + Dolor sit amet. + + Returns: + 'True' is something happens, else 'False'. + """ def infer_types(): diff --git a/tests/data/docstring_parser_package/numpydoc.py b/tests/data/docstring_parser_package/numpydoc.py index 57c8a7b0..c965301c 100644 --- a/tests/data/docstring_parser_package/numpydoc.py +++ b/tests/data/docstring_parser_package/numpydoc.py @@ -3,7 +3,7 @@ A module for testing the various docstring types. """ -from typing import Any, Optional +from typing import Any, Optional, Callable, Mapping from enum import Enum from tests.data.various_modules_package.another_path.another_module import AnotherClass @@ -363,6 +363,10 @@ class ClassWithVariousAttributeTypes: optional_type_2 : Optional[int] class_type : ClassWithAttributes imported_type : AnotherClass + callable_type : Callable[[int], str] + mapping_type : Mapping[int, str] + bool_op_type : int or str or bool + list_type_5 : [int] """ no_type = "" optional_type = "" @@ -388,6 +392,22 @@ class ClassWithVariousAttributeTypes: optional_type_2: Optional[int] class_type: ClassWithAttributes imported_type: AnotherClass + callable_type: Callable[[int], str] + mapping_type: Mapping[int, str] + bool_op_type: int | str | bool + list_type_5: list[int] + + +def uninferable_return_doc(): + """ + uninferable_return_doc. + + Dolor sit amet. + + Returns + ------- + 'True' is something happens, else 'False'. + """ def infer_types(): diff --git a/tests/data/docstring_parser_package/restdoc.py b/tests/data/docstring_parser_package/restdoc.py index ef420705..64438e43 100644 --- a/tests/data/docstring_parser_package/restdoc.py +++ b/tests/data/docstring_parser_package/restdoc.py @@ -241,6 +241,17 @@ def infer_types2(a, b): return True +def uninferable_return_doc(): + """ + uninferable_return_doc. + + Dolor sit amet. + + :return: return value + :rtype: 'True' is something happens, else 'False'. + """ + + # Todo Currently disabled, since Griffe can't analyze ReST (Sphinx) attributes (see issue #98) # class ClassWithVariousAttributeTypes: # """ @@ -295,6 +306,14 @@ def infer_types2(a, b): # :type any_type: Any # :var optional_type_2: # :type optional_type_2: Optional[int] +# :var callable_type: +# :type callable_type: Callable[[int], str] +# :var mapping_type: +# :type mapping_type: Mapping[int, str] +# :var bool_op_type: +# :type bool_op_type: int or str or bool +# :var list_type_5: +# :type list_type_5: [int] # """ # has_default = 1 # optional_int = None @@ -324,3 +343,7 @@ def infer_types2(a, b): # optional_type_2: Optional[int] # class_type: ClassWithMethod # imported_type: AnotherClass +# callable_type: Callable[[int], str] +# mapping_type: Mapping[int, str] +# bool_op_type: int | str | bool +# list_type_5: list[int] diff --git a/tests/safeds_stubgen/api_analyzer/test__get_api.py b/tests/safeds_stubgen/api_analyzer/test__get_api.py index d91b4fab..b6ea5ab1 100644 --- a/tests/safeds_stubgen/api_analyzer/test__get_api.py +++ b/tests/safeds_stubgen/api_analyzer/test__get_api.py @@ -18,27 +18,23 @@ package_root = Path(_test_dir / "data" / _test_package_name) api_data_paintext = get_api( - package_name=_test_package_name, root=package_root, is_test_run=True, ).to_dict() api_data_numpy = get_api( - package_name=_test_package_name, root=package_root, docstring_style=DocstringStyle.NUMPYDOC, is_test_run=True, ).to_dict() api_data_rest = get_api( - package_name=_test_package_name, root=package_root, docstring_style=DocstringStyle.REST, is_test_run=True, ).to_dict() api_data_google = get_api( - package_name=_test_package_name, root=package_root, docstring_style=DocstringStyle.GOOGLE, is_test_run=True, @@ -139,7 +135,6 @@ def test_modules(python_file: Path, snapshot: SnapshotAssertion) -> None: raise pytest.fail(f"Could not find module data for '{file_name}'.") -# Todo new tests after issue #38 @pytest.mark.parametrize( argnames=("module_name", "import_type"), argvalues=[ diff --git a/tests/safeds_stubgen/api_analyzer/test_api.py b/tests/safeds_stubgen/api_analyzer/test_api.py index aa668192..3c232fc6 100644 --- a/tests/safeds_stubgen/api_analyzer/test_api.py +++ b/tests/safeds_stubgen/api_analyzer/test_api.py @@ -30,7 +30,7 @@ def test_parameter( is_optional=True, default_value=default_value, assigned_by=assigned_by, - docstring=ParameterDocstring("'hashvalue'", "r", "r"), + docstring=ParameterDocstring(None, "r", "r"), type=NamedType(name="str", qname=""), ) diff --git a/tests/safeds_stubgen/api_analyzer/test_types.py b/tests/safeds_stubgen/api_analyzer/test_types.py index a19c4ddf..5a8e3675 100644 --- a/tests/safeds_stubgen/api_analyzer/test_types.py +++ b/tests/safeds_stubgen/api_analyzer/test_types.py @@ -18,6 +18,7 @@ TupleType, TypeVarType, UnionType, + UnknownType, ) from safeds_stubgen.docstring_parsing import AttributeDocstring, ParameterDocstring @@ -167,6 +168,19 @@ def test_callable_type() -> None: CallableType([NamedType("b", "")], NamedType("a", "")), ) + assert CallableType([NamedType("a", ""), LiteralType(["b"])], NamedType("c", "")) == CallableType( + [LiteralType(["b"]), NamedType("a", "")], + NamedType("c", ""), + ) + assert CallableType([NamedType("a", ""), LiteralType(["b"])], NamedType("c", "")) != CallableType( + [LiteralType(["a"]), NamedType("b", "")], + NamedType("c", ""), + ) + assert CallableType([NamedType("a", ""), NamedType("b", "")], NamedType("c", "")) != CallableType( + [NamedType("a", ""), NamedType("c", "")], + NamedType("c", ""), + ) + def test_list_type() -> None: list_type = ListType([NamedType("str", "builtins.str"), NamedType("int", "builtins.int")]) @@ -187,6 +201,10 @@ def test_list_type() -> None: assert ListType([NamedType("a", "")]) != ListType([NamedType("b", "")]) assert hash(ListType([NamedType("a", "")])) != hash(ListType([NamedType("b", "")])) + assert ListType([NamedType("a", ""), LiteralType(["b"])]) == ListType([LiteralType(["b"]), NamedType("a", "")]) + assert ListType([NamedType("a", ""), LiteralType(["b"])]) != ListType([LiteralType(["a"]), NamedType("b", "")]) + assert ListType([NamedType("a", ""), NamedType("b", "")]) != ListType([NamedType("a", ""), NamedType("c", "")]) + def test_dict_type() -> None: dict_type = DictType( @@ -244,6 +262,10 @@ def test_set_type() -> None: assert SetType([NamedType("a", "")]) != SetType([NamedType("b", "")]) assert hash(SetType([NamedType("a", "")])) != hash(SetType([NamedType("b", "")])) + assert SetType([NamedType("a", ""), LiteralType(["b"])]) == SetType([LiteralType(["b"]), NamedType("a", "")]) + assert SetType([NamedType("a", ""), LiteralType(["b"])]) != SetType([LiteralType(["a"]), NamedType("b", "")]) + assert SetType([NamedType("a", ""), NamedType("b", "")]) != SetType([NamedType("a", ""), NamedType("c", "")]) + def test_literal_type() -> None: type_ = LiteralType(["Literal_1", 2]) @@ -261,6 +283,10 @@ def test_literal_type() -> None: assert LiteralType(["a"]) != LiteralType(["b"]) assert hash(LiteralType(["a"])) != hash(LiteralType(["b"])) + assert LiteralType(["a", 1]) == LiteralType([1, "a"]) + assert LiteralType(["a", 1]) != LiteralType(["a", "1"]) + assert LiteralType(["a", "b"]) != LiteralType(["a", "c"]) + def test_type_var_type() -> None: type_ = TypeVarType("_T") @@ -293,6 +319,15 @@ def test_final_type() -> None: assert hash(FinalType(NamedType("a", ""))) != hash(FinalType(NamedType("b", ""))) +def test_unknown_type() -> None: + type_ = UnknownType() + type_dict = {"kind": "UnknownType"} + + assert AbstractType.from_dict(type_dict) == type_ + assert UnknownType.from_dict(type_dict) == type_ + assert type_.to_dict() == type_dict + + def test_tuple_type() -> None: set_type = TupleType([NamedType("str", "builtins.str"), NamedType("int", "builtins.int")]) set_type_dict = { @@ -312,6 +347,10 @@ def test_tuple_type() -> None: assert TupleType([NamedType("a", "")]) != TupleType([NamedType("b", "")]) assert hash(TupleType([NamedType("a", "")])) != hash(TupleType([NamedType("b", "")])) + assert TupleType([NamedType("a", ""), LiteralType(["b"])]) == TupleType([LiteralType(["b"]), NamedType("a", "")]) + assert TupleType([NamedType("a", ""), LiteralType(["b"])]) != TupleType([LiteralType(["a"]), NamedType("b", "")]) + assert TupleType([NamedType("a", ""), NamedType("b", "")]) != TupleType([NamedType("a", ""), NamedType("c", "")]) + def test_abstract_type_from_dict_exception() -> None: with pytest.raises(ValueError, match="Cannot parse unknown_type value."): diff --git a/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[googledoc-GOOGLE].sdsstub b/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[googledoc-GOOGLE].sdsstub index 40e2fd3c..3e4521ce 100644 --- a/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[googledoc-GOOGLE].sdsstub +++ b/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[googledoc-GOOGLE].sdsstub @@ -106,6 +106,18 @@ fun functionWithMultipleResults() -> (result1: Int, result2: Boolean) @PythonName("function_without_return_value") fun functionWithoutReturnValue() +// TODO Result type information missing. +/** + * uninferable_return_doc. + * + * Dolor sit amet. + * + * @result result1 'True' is something happens, else 'False'. + */ +@Pure +@PythonName("uninferable_return_doc") +fun uninferableReturnDoc() + /** * property_method_with_docstring. * @@ -283,6 +295,15 @@ class ClassWithVariousAttributeTypes() { static attr classType: ClassWithAttributes @PythonName("imported_type") static attr importedType: AnotherClass + // TODO Attribute has no type information. + @PythonName("callable_type") + static attr callableType + @PythonName("mapping_type") + static attr mappingType: Map + @PythonName("bool_op_type") + static attr boolOpType: union + @PythonName("list_type_5") + static attr listType5: List } /** diff --git a/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[numpydoc-NUMPYDOC].sdsstub b/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[numpydoc-NUMPYDOC].sdsstub index 64d8bf85..35943ec0 100644 --- a/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[numpydoc-NUMPYDOC].sdsstub +++ b/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[numpydoc-NUMPYDOC].sdsstub @@ -103,6 +103,16 @@ fun functionWithMultipleResults() -> (firstResult: Int, secondResult: Boolean) @PythonName("function_without_result_value") fun functionWithoutResultValue() +// TODO Result type information missing. +/** + * uninferable_return_doc. + * + * Dolor sit amet. + */ +@Pure +@PythonName("uninferable_return_doc") +fun uninferableReturnDoc() + /** * property_method_with_docstring. * @@ -382,6 +392,15 @@ class ClassWithVariousAttributeTypes() { static attr classType: ClassWithAttributes @PythonName("imported_type") static attr importedType: AnotherClass + // TODO Attribute has no type information. + @PythonName("callable_type") + static attr callableType + @PythonName("mapping_type") + static attr mappingType: Map + @PythonName("bool_op_type") + static attr boolOpType: union + @PythonName("list_type_5") + static attr listType5: List } /** diff --git a/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[restdoc-REST].sdsstub b/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[restdoc-REST].sdsstub index f56bb01b..2b293e71 100644 --- a/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[restdoc-REST].sdsstub +++ b/tests/safeds_stubgen/stubs_generator/__snapshots__/test_generate_stubs/test_stub_docstring_creation[restdoc-REST].sdsstub @@ -119,6 +119,18 @@ fun inferTypes2( b ) -> result1: union +// TODO Result type information missing. +/** + * uninferable_return_doc. + * + * Dolor sit amet. + * + * @result result1 return value + */ +@Pure +@PythonName("uninferable_return_doc") +fun uninferableReturnDoc() + /** * ClassWithDocumentation. Code:: * diff --git a/tests/safeds_stubgen/stubs_generator/test_generate_stubs.py b/tests/safeds_stubgen/stubs_generator/test_generate_stubs.py index ae592123..495b3a71 100644 --- a/tests/safeds_stubgen/stubs_generator/test_generate_stubs.py +++ b/tests/safeds_stubgen/stubs_generator/test_generate_stubs.py @@ -32,7 +32,7 @@ _docstring_package_name = "docstring_parser_package" _docstring_package_dir = Path(_lib_dir / "data" / _docstring_package_name) -api = get_api(_test_package_name, _test_package_dir, is_test_run=True) +api = get_api(_test_package_dir, is_test_run=True) stubs_generator = StubsStringGenerator(api, naming_convention=NamingConvention.SAFE_DS) stubs_data = _generate_stubs_data(api, _out_dir, stubs_generator) @@ -157,8 +157,7 @@ def test_stub_docstring_creation( snapshot_sds_stub: SnapshotAssertion, ) -> None: docstring_api = get_api( - _docstring_package_name, - _docstring_package_dir, + root=_docstring_package_dir, docstring_style=docstring_style, is_test_run=True, ) diff --git a/tests/safeds_stubgen/test_main.py b/tests/safeds_stubgen/test_main.py index bd25f416..f7774e4c 100644 --- a/tests/safeds_stubgen/test_main.py +++ b/tests/safeds_stubgen/test_main.py @@ -19,8 +19,6 @@ def test_main(snapshot: SnapshotAssertion) -> None: sys.argv = [ str(_main_dir), "-v", - "-p", - str(_test_package_name), "-s", str(_test_package_dir), "-o", @@ -44,8 +42,6 @@ def test_main_empty() -> None: sys.argv = [ str(_main_dir), "-v", - "-p", - str(_test_package_name), "-s", str(_test_package_dir), "-o",