8 Commits
6 changed files with 264 additions and 11 deletions
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Optional
from midas.ast.location import Location
from midas.checker.registry import TypesRegistry
from midas.checker.reporter import FileReporter
from midas.checker.types import (
ColumnType,
DataFrameType,
Function,
Type,
UnknownType,
unfold_type,
)
if TYPE_CHECKING:
from midas.checker.python import PythonTyper, TypedExpr
@staticmethod
def frame_method(*names: str):
def wrapper(func):
names_: tuple[str, ...] = names
if len(names_) == 0:
names_ = (func.__name__,)
setattr(func, "__method_names__", names_)
return func
return wrapper
@dataclass(frozen=True, kw_only=True)
class Call:
location: Location
frame: DataFrameType
positional: list[TypedExpr]
keywords: dict[str, TypedExpr]
class _MethodRegistryMeta(type):
_methods: dict[str, Callable] = {}
def __new__(
cls,
name: str,
bases: tuple[type, ...],
namespace: dict[str, Any],
):
new_class = super().__new__(cls, name, bases, namespace)
new_class._methods = {}
for attr in namespace.values():
if callable(attr) and hasattr(attr, "__method_names__"):
for name in attr.__method_names__: # type: ignore
new_class._methods[name] = attr
return new_class
class MethodRegistry(metaclass=_MethodRegistryMeta):
def __init__(self, typer: PythonTyper) -> None:
self.typer: PythonTyper = typer
@property
def reporter(self) -> FileReporter:
return self.typer.reporter
@property
def types(self) -> TypesRegistry:
return self.typer.types
def call(
self,
method: str,
call: Call,
) -> Type:
func: Optional[Callable] = self._methods.get(method)
if func is None:
self.reporter.error(call.location, f"Unknown method {method}")
return UnknownType()
return func(self, call)
@frame_method("add", "__add__")
def add(
self,
call: Call,
) -> Type:
new_columns: list[DataFrameType.Column] = []
by_name: dict[str, DataFrameType.Column] = {}
frame2: Optional[DataFrameType] = None
if len(call.positional) != 0:
other: Type = call.positional[0][1]
unfolded_other: Type = unfold_type(other)
if isinstance(unfolded_other, DataFrameType):
frame2 = unfolded_other
by_name = {
col.name: col for col in frame2.columns if col.name is not None
}
in_frame1: set[str] = set()
for column in call.frame.columns:
if column.name is not None:
in_frame1.add(column.name)
col_type1: Type = column.type
col_type: Type = ColumnType(type=UnknownType())
if column.name in by_name:
column2 = by_name[column.name]
col_type2: Type = column2.type
if self.types.are_equivalent(col_type2, col_type1):
col_type = col_type1
new_column = DataFrameType.Column(
index=column.index,
name=column.name,
type=col_type,
)
new_columns.append(new_column)
if frame2 is not None:
for column in frame2.columns:
if column.name in in_frame1:
continue
new_columns.append(
DataFrameType.Column(
index=len(new_columns),
name=column.name,
type=ColumnType(type=UnknownType()),
)
)
signature = Function(
args=[
Function.Argument(
pos=0,
name="other",
type=DataFrameType(columns=[]),
required=True,
),
],
returns=DataFrameType(columns=new_columns),
)
return (
self.typer._get_call_result(
location=call.location,
callee=signature,
positional=call.positional,
keywords=call.keywords,
)
or UnknownType()
)
+26 -5
View File
@@ -1,11 +1,15 @@
from typing import Optional, TypeGuard, cast from __future__ import annotations
from typing import TYPE_CHECKING, Optional, TypeGuard, cast
import midas.ast.python as p
from midas.ast.location import Location from midas.ast.location import Location
from midas.checker.registry import TypesRegistry from midas.checker.frame_methods import Call, MethodRegistry
from midas.checker.reporter import FileReporter from midas.checker.reporter import FileReporter
from midas.checker.types import ColumnType, DataFrameType, TupleType, Type, UnknownType from midas.checker.types import ColumnType, DataFrameType, TupleType, Type, UnknownType
import midas.ast.python as p if TYPE_CHECKING:
from midas.checker.python import PythonTyper, TypedExpr
def is_list_of_literals(exprs: list[p.Expr]) -> TypeGuard[list[p.LiteralExpr]]: def is_list_of_literals(exprs: list[p.Expr]) -> TypeGuard[list[p.LiteralExpr]]:
@@ -13,8 +17,9 @@ def is_list_of_literals(exprs: list[p.Expr]) -> TypeGuard[list[p.LiteralExpr]]:
class FrameManager: class FrameManager:
def __init__(self, types: TypesRegistry) -> None: def __init__(self, typer: PythonTyper) -> None:
self.types: TypesRegistry = types self.typer: PythonTyper = typer
self.method_resolver: MethodRegistry = MethodRegistry(self.typer)
def assign( def assign(
self, self,
@@ -131,3 +136,19 @@ class FrameManager:
cls, frame: DataFrameType, names: list[str] cls, frame: DataFrameType, names: list[str]
) -> list[Optional[ColumnType]]: ) -> list[Optional[ColumnType]]:
return [cls._get_column(frame, name) for name in names] return [cls._get_column(frame, name) for name in names]
def call(
self,
method: str,
location: Location,
frame: DataFrameType,
positional: list[TypedExpr],
keywords: dict[str, TypedExpr],
) -> Type:
call: Call = Call(
location=location,
frame=frame,
positional=positional,
keywords=keywords,
)
return self.method_resolver.call(method, call)
+19 -5
View File
@@ -75,7 +75,7 @@ class PythonTyper(
self.logger: logging.Logger = logging.getLogger("PythonTyper") self.logger: logging.Logger = logging.getLogger("PythonTyper")
self.reporter: FileReporter = reporter.for_file(None) self.reporter: FileReporter = reporter.for_file(None)
self.types: TypesRegistry = types self.types: TypesRegistry = types
self.frame_mgr: FrameManager = FrameManager(self.types) self.frame_mgr: FrameManager = FrameManager(self)
self.global_env: Environment = Preamble(self.types) self.global_env: Environment = Preamble(self.types)
self.env: Environment = self.global_env self.env: Environment = self.global_env
self.locals: dict[p.Expr, int] = {} self.locals: dict[p.Expr, int] = {}
@@ -515,13 +515,27 @@ class PythonTyper(
case p.VariableExpr(name="TypeVar"): case p.VariableExpr(name="TypeVar"):
return self.define_typevar(expr) or UnknownType() return self.define_typevar(expr) or UnknownType()
callee: Type = self.type_of(expr.callee)
positional: list[TypedExpr] = [ positional: list[TypedExpr] = [
(arg, self.type_of(arg)) for arg in expr.arguments (arg, self.type_of(arg)) for arg in expr.arguments
] ]
keywords: dict[str, TypedExpr] = { keywords: dict[str, TypedExpr] = {
name: (arg, self.type_of(arg)) for name, arg in expr.keywords.items() name: (arg, self.type_of(arg)) for name, arg in expr.keywords.items()
} }
match expr.callee:
case p.GetExpr(object=obj, name=method):
obj_type: Type = self.type_of(obj)
unfolded: Type = unfold_type(obj_type)
if isinstance(unfolded, DataFrameType):
return self.frame_mgr.call(
method,
expr.location,
unfolded,
positional,
keywords,
)
callee: Type = self.type_of(expr.callee)
return ( return (
self._get_call_result( self._get_call_result(
location=expr.location, location=expr.location,
@@ -626,7 +640,7 @@ class PythonTyper(
return self.types.apply_generic(list_type, [item_type]) return self.types.apply_generic(list_type, [item_type])
self.reporter.error( self.reporter.error(
expr.location, expr.location,
f"Heterogeneous list items: {item_types}", f"Heterogeneous list items: [{', '.join(map(str, item_types))}]",
) )
return self.types.apply_generic(list_type, [UnknownType()]) return self.types.apply_generic(list_type, [UnknownType()])
@@ -658,7 +672,7 @@ class PythonTyper(
else: else:
self.reporter.error( self.reporter.error(
expr.location, expr.location,
f"Heterogeneous dict keys: {key_types}", f"Heterogeneous dict keys: [{', '.join(map(str, key_types))}]",
) )
if len(value_types) == 1: if len(value_types) == 1:
@@ -666,7 +680,7 @@ class PythonTyper(
else: else:
self.reporter.error( self.reporter.error(
expr.location, expr.location,
f"Heterogeneous dict values: {value_types}", f"Heterogeneous dict values: [{', '.join(map(str, value_types))}]",
) )
return self.types.apply_generic(dict_type, [key_type, value_type]) return self.types.apply_generic(dict_type, [key_type, value_type])
+23
View File
@@ -8,8 +8,10 @@ from midas.checker.types import (
AliasType, AliasType,
AppliedType, AppliedType,
BaseType, BaseType,
ColumnType,
ComplexType, ComplexType,
ConstraintType, ConstraintType,
DataFrameType,
ExtensionType, ExtensionType,
Function, Function,
GenericType, GenericType,
@@ -157,6 +159,24 @@ class TypesRegistry:
return False return False
return True return True
case (DataFrameType(columns=columns1), DataFrameType(columns=columns2)):
# TODO: check order?
by_name1: dict[str, DataFrameType.Column] = {
col.name: col for col in columns1 if col.name is not None
}
for col2 in columns2:
if col2.name not in by_name1:
return False
if not self.is_subtype(by_name1[col2.name].type, col2.type):
return False
return True
case (ColumnType(type=inner1), ColumnType(type=inner2)):
# TODO: invariant, replace ColumnType with simple GenericType
if not self.are_equivalent(inner1, inner2):
return False
return True
case (Function(), Function()): case (Function(), Function()):
return self.is_func_subtype(type1, type2) return self.is_func_subtype(type1, type2)
@@ -187,6 +207,9 @@ class TypesRegistry:
return False return False
def are_equivalent(self, type1: Type, type2: Type) -> bool:
return self.is_subtype(type1, type2) and self.is_subtype(type2, type1)
# TODO: verify the logic in here # TODO: verify the logic in here
def is_func_subtype(self, func1: Function, func2: Function) -> bool: def is_func_subtype(self, func1: Function, func2: Function) -> bool:
"""Check whether a function is a subtype of another """Check whether a function is a subtype of another
+25 -1
View File
@@ -68,7 +68,7 @@ class DiagnosticPrinter:
loc: Location = diagnostic.location loc: Location = diagnostic.location
if loc.lineno != loc.end_lineno: if loc.lineno != loc.end_lineno:
print(diagnostic) self.print_multiline(lines, diagnostic, indent)
return return
start_offset: int = loc.col_offset start_offset: int = loc.col_offset
@@ -95,3 +95,27 @@ class DiagnosticPrinter:
print(indent_str + before + subject + after) print(indent_str + before + subject + after)
print(indent_str + cursor) print(indent_str + cursor)
print() print()
def print_multiline(
self, all_lines: list[str], diagnostic: Diagnostic, indent: int = 4
):
loc: Location = diagnostic.location
lines: list[str] = all_lines[loc.lineno - 1 : loc.end_lineno]
start_offset: int = loc.col_offset
end_offset: int = loc.end_col_offset or (start_offset + 1)
indent_str: str = " " * indent
color: int = self.COLORS.get(diagnostic.type, Ansi.WHITE)
res: str = indent_str + lines[0][:start_offset]
res += Ansi.FG(color) + lines[0][start_offset:]
for line in lines[1:-1]:
res += "\n" + indent_str + line
res += "\n" + indent_str + lines[-1][:end_offset]
res += Ansi.RESET + lines[-1][end_offset:]
print(diagnostic.location_str + ":")
print(res)
print()
print(Ansi.FG(color) + diagnostic.message + Ansi.RESET)
print()
+18
View File
@@ -1,3 +1,4 @@
from typing import Generic, TypeVar
from typing import cast as typing_cast from typing import cast as typing_cast
cast = typing_cast cast = typing_cast
@@ -32,3 +33,20 @@ This operation is unsound, use at your own risk!
_**Internal Python documentation**_ _**Internal Python documentation**_
""" """
T = TypeVar("T")
class Frame(Generic[T]):
"""A `Frame` is the abstract type implemented by `DataFrame`
A frame contains any number of named columns (see :class:`Column`)
"""
class Column(Generic[T]):
"""A `Column` is the abstract type implemented by `Series`
A column contains a any number of values of the same type
"""