Skip to content

Commit

Permalink
fix: Fixed the stubs generator (#108)
Browse files Browse the repository at this point in the history
Closes #80 

### Summary of Changes
Fixed the stub generator, it should now run with the Library project.

To run the script, use the following command:
`safe-ds-stubgen -p "Library" -o "path/to/out" -nc -s
"path/to/Library/src" --docstyle NUMPYDOC`

---------

Co-authored-by: megalinter-bot <129584137+megalinter-bot@users.noreply.github.com>
  • Loading branch information
Masara and megalinter-bot authored Apr 25, 2024
1 parent 6665186 commit 9ad6df6
Show file tree
Hide file tree
Showing 21 changed files with 344 additions and 134 deletions.
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

0 comments on commit 9ad6df6

Please sign in to comment.