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

fix: Fixed the stubs generator #108

Merged
merged 18 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/safeds_stubgen/api_analyzer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,6 +34,7 @@
TupleType,
TypeVarType,
UnionType,
UnknownType,
)

__all__ = [
Expand All @@ -58,7 +59,6 @@
"LiteralType",
"Module",
"NamedType",
"package_root",
"Parameter",
"ParameterAssignment",
"QualifiedImport",
Expand All @@ -68,6 +68,7 @@
"TupleType",
"TypeVarType",
"UnionType",
"UnknownType",
"VarianceKind",
"WildcardImport",
]
40 changes: 17 additions & 23 deletions src/safeds_stubgen/api_analyzer/_ast_visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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:
Expand All @@ -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

Expand Down
39 changes: 15 additions & 24 deletions src/safeds_stubgen/api_analyzer/_ast_walker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 32 additions & 13 deletions src/safeds_stubgen/api_analyzer/_get_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ""
Expand All @@ -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)
Expand Down
9 changes: 0 additions & 9 deletions src/safeds_stubgen/api_analyzer/_package_metadata.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
42 changes: 42 additions & 0 deletions src/safeds_stubgen/api_analyzer/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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]))

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

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

Expand Down Expand Up @@ -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))

Expand Down
Loading