Compare commits
4
Commits
4d343a61ff
...
c3b243288d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3b243288d
|
||
|
|
cd9b80d22b
|
||
|
|
21b648e18f
|
||
|
|
742693fa38
|
+844
-807
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -624,7 +624,7 @@ For example:
|
||||
|
||||
== Control flow
|
||||
|
||||
Some control flow features are supported. For the limited code of this project, not all constructs are supported. The following are those currently handled and typ checked by Midas.
|
||||
Some control flow features are supported. For the limited code of this project, not all constructs are supported. The following are those currently handled and type checked by Midas.
|
||||
|
||||
=== `if` / `elif` / `else` <if-else>
|
||||
|
||||
@@ -757,7 +757,7 @@ If the value passed to `cast` or `unsafe_cast` is a literal (e.g. an integer, a
|
||||
|
||||
Vanilla Python already lets you use type hints to specify the type of variables and function parameters.
|
||||
|
||||
Midas use them to type check your code. Additionally, it allows you to use a special syntax to define a `Frame` types directly in these annotations.
|
||||
Midas use them to type check your code. Additionally, it allows you to use a special syntax to define a `Frame` type directly in these annotations.
|
||||
|
||||
Because these annotations are not interpretable by Python, your integrated type checker might complain loudly about them being invalid.
|
||||
A workaround is to silence it by adding a type comment at the end of the line, as shown in @silence-errors.
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
set document(
|
||||
title: title,
|
||||
author: author,
|
||||
date: none,
|
||||
)
|
||||
set text(
|
||||
font: "Source Sans 3",
|
||||
|
||||
@@ -26,7 +26,11 @@ Circular dependencies and diamond inheritance MUST be avoided
|
||||
|
||||
|
||||
def define_builtins(reg: TypesRegistry):
|
||||
"""Define builtin types and operations"""
|
||||
"""Define builtin types and operations
|
||||
|
||||
Args:
|
||||
reg (TypesRegistry): the types registry
|
||||
"""
|
||||
any = reg.define_type("Any", TopType())
|
||||
unit = reg.define_type("None", UnitType())
|
||||
object = reg.define_type("object", BaseType(name="object"))
|
||||
|
||||
@@ -102,6 +102,11 @@ class CallDispatcher(Generic[E]):
|
||||
self.logger: logging.Logger = logging.getLogger("CallDispatcher")
|
||||
|
||||
def set_reporter(self, reporter: FileReporter):
|
||||
"""Set the current reporter
|
||||
|
||||
Args:
|
||||
reporter (FileReporter): the new file reporter
|
||||
"""
|
||||
self.reporter = reporter
|
||||
|
||||
def get_result(
|
||||
@@ -123,8 +128,8 @@ class CallDispatcher(Generic[E]):
|
||||
Args:
|
||||
location (Location): the call location
|
||||
callee (Type): the called function
|
||||
positional (list[TypedExpr]): the list of positional arguments
|
||||
keywords (dict[str, TypedExpr]): the map of keyword arguments
|
||||
positional (list[TypedExpr[E]]): the list of positional arguments
|
||||
keywords (dict[str, TypedExpr[E]]): the map of keyword arguments
|
||||
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
|
||||
|
||||
Returns:
|
||||
@@ -250,7 +255,7 @@ class CallDispatcher(Generic[E]):
|
||||
"""Check whether the passed argument types correspond to their matched parameter definitions
|
||||
|
||||
Args:
|
||||
arguments (list[MappedArgument]): the list of argument/parameter pairs
|
||||
arguments (list[MappedArgument[E]]): the list of argument/parameter pairs
|
||||
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
|
||||
|
||||
Returns:
|
||||
@@ -286,8 +291,8 @@ class CallDispatcher(Generic[E]):
|
||||
Args:
|
||||
overloads (list[Type]): the list of possible overloads
|
||||
location (Location): the call location
|
||||
positional (list[TypedExpr]): the list of positional arguments
|
||||
keywords (dict[str, TypedExpr]): the map of keywords arguments
|
||||
positional (list[TypedExpr[E]]): the list of positional arguments
|
||||
keywords (dict[str, TypedExpr[E]]): the map of keywords arguments
|
||||
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
|
||||
|
||||
Returns:
|
||||
@@ -385,8 +390,8 @@ class CallDispatcher(Generic[E]):
|
||||
Args:
|
||||
function (Function): the function definition
|
||||
location (Location): the call location
|
||||
positional (list[TypedExpr]): the list of positional arguments
|
||||
keywords (dict[str, TypedExpr]): the map of keyword arguments
|
||||
positional (list[TypedExpr[E]]): the list of positional arguments
|
||||
keywords (dict[str, TypedExpr[E]]): the map of keyword arguments
|
||||
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
|
||||
|
||||
Returns:
|
||||
@@ -514,8 +519,8 @@ class CallDispatcher(Generic[E]):
|
||||
function / a subtype of another.
|
||||
|
||||
Args:
|
||||
mapped1 (list[MappedArgument]): the first argument mappings (subtype)
|
||||
mapped2 (list[MappedArgument]): the second argument mappings (supertype)
|
||||
mapped1 (list[MappedArgument[E]]): the first argument mappings (subtype)
|
||||
mapped2 (list[MappedArgument[E]]): the second argument mappings (supertype)
|
||||
|
||||
Returns:
|
||||
bool: `True` if `mapped1` is a subtype of `mapped2`, `False` otherwise
|
||||
|
||||
@@ -190,6 +190,7 @@ class Evaluator(m.Expr.Visitor[Any]):
|
||||
"""Evaluate a predicate function call
|
||||
|
||||
Args:
|
||||
location (Location): the location of the call expression
|
||||
predicate (Predicate): the predicate to evaluate
|
||||
args (list[Any]): a list of positional arguments
|
||||
kwargs (dict[str, Any]): a map of keyword arguments
|
||||
@@ -234,6 +235,7 @@ class Evaluator(m.Expr.Visitor[Any]):
|
||||
is set in the context using :func:`set_value` with the parameter's name
|
||||
|
||||
Args:
|
||||
location (Location): the location of the call expression
|
||||
function (Function): the called function
|
||||
args (list[Any]): a list of positional arguments
|
||||
kwargs (dict[str, Any]): a map of keyword arguments
|
||||
|
||||
@@ -45,7 +45,7 @@ class ColumnManager:
|
||||
Args:
|
||||
reporter (FileReporter): the file reporter to use for diagnostics
|
||||
location (Location): the subscript's location
|
||||
column (DataFrameType): the column type
|
||||
column (ColumnType): the column type
|
||||
index (TypedExpr): the index
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -353,10 +353,12 @@ class ColumnMethodRegistry(MethodRegistry[Call]):
|
||||
call (Call): the call object
|
||||
kwargs (list[Function.Parameter], optional): a list of extra
|
||||
keyword-only parameters. Defaults to [].
|
||||
formula (Callable[[Type], Formula], optional): optional formula
|
||||
builder function to compute the return type. If set, the function
|
||||
should accept the inner column type and return a formula.
|
||||
If `None`, the result is typed as `Column[Any]`. Defaults to None.
|
||||
formula (Optional[Callable[[Type], Formula]], optional):
|
||||
optional formula builder function to compute the return type.<br>
|
||||
If set, the function should accept the inner column type and
|
||||
return a formula.<br>
|
||||
If `None`, the result is typed as `Column[Any]`.
|
||||
Defaults to None.
|
||||
|
||||
Returns:
|
||||
Type: the result type
|
||||
|
||||
+27
-9
@@ -486,11 +486,10 @@ class PythonTyper(
|
||||
self._assign_sub(location, var, index, value_type)
|
||||
|
||||
case _:
|
||||
if not isinstance(target, p.VariableExpr):
|
||||
self.logger.warning(f"Unsupported assignment to {target}")
|
||||
self.reporter.warning(
|
||||
target.location, f"Unsupported assignment to {target}"
|
||||
)
|
||||
self.logger.warning(f"Unsupported assignment to {target}")
|
||||
self.reporter.warning(
|
||||
target.location, f"Unsupported assignment to {target}"
|
||||
)
|
||||
|
||||
def _assign_var(self, location: Location, target: p.VariableExpr, value_type: Type):
|
||||
"""Type check assignment to the given target
|
||||
@@ -518,11 +517,12 @@ class PythonTyper(
|
||||
def _assign_attr(
|
||||
self, location: Location, object: p.Expr, name: str, value_type: Type
|
||||
):
|
||||
"""Type check assignment to the given target
|
||||
"""Type check assignment to the given attribute target
|
||||
|
||||
Args:
|
||||
location (Location): the location of the assignment
|
||||
target (p.VariableExpr): the assignment's target
|
||||
object (p.Expr): the target attribute's owner object
|
||||
name (str): the target attribute's name
|
||||
value_type (Type): the value to be assigned
|
||||
"""
|
||||
object_type: Type = self.type_of(object)
|
||||
@@ -544,11 +544,15 @@ class PythonTyper(
|
||||
index: p.Expr,
|
||||
value_type: Type,
|
||||
):
|
||||
"""Type check assignment to the given target
|
||||
"""Type check assignment to the given subscript target
|
||||
|
||||
Args:
|
||||
location (Location): the location of the assignment
|
||||
target (p.VariableExpr): the assignment's target
|
||||
var (p.VariableExpr): the target subscript's owner. We only allow
|
||||
a variable expression here because we might modify its type (for
|
||||
example when assigning a column to a dataframe) and reference
|
||||
types are not implemented
|
||||
index (p.Expr): the target subscript's index expression
|
||||
value_type (Type): the value to be assigned
|
||||
"""
|
||||
var_type: Type = self.type_of(var)
|
||||
@@ -690,6 +694,20 @@ class PythonTyper(
|
||||
right: TypedExpr,
|
||||
method: str,
|
||||
) -> Type:
|
||||
"""Compute the result type of a binary operation method call
|
||||
|
||||
This method is called for dunder methods called by binary operators
|
||||
|
||||
Args:
|
||||
location (Location): the location of the operation
|
||||
expr (p.Expr): the expression which triggered this resolution
|
||||
left (TypedExpr): the left operand
|
||||
right (TypedExpr): the right operand
|
||||
method (str): the method name
|
||||
|
||||
Returns:
|
||||
Type: the result type
|
||||
"""
|
||||
try:
|
||||
return self.call_method(
|
||||
location=location,
|
||||
|
||||
@@ -70,6 +70,11 @@ class FileReporter:
|
||||
|
||||
@contextmanager
|
||||
def with_context(self, ctx: str):
|
||||
"""Push given context for reports inside this manager and pop it on exit
|
||||
|
||||
Args:
|
||||
ctx (str): the context to temporarily push on the stack
|
||||
"""
|
||||
self._context.append(ctx)
|
||||
try:
|
||||
yield
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import ast
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArgDoc:
|
||||
name: str
|
||||
type: str
|
||||
optional: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Param:
|
||||
name: str
|
||||
annotation: Optional[str]
|
||||
optional: bool
|
||||
|
||||
|
||||
class Checker(ast.NodeVisitor):
|
||||
def _get_args(self, docstring: str) -> list[ArgDoc]:
|
||||
args: list[ArgDoc] = []
|
||||
|
||||
in_args: bool = False
|
||||
for line in docstring.splitlines():
|
||||
if not in_args:
|
||||
if line == "Args:":
|
||||
in_args = True
|
||||
continue
|
||||
|
||||
# End of args
|
||||
if not line.startswith(" "):
|
||||
break
|
||||
|
||||
# Continuation line
|
||||
if line.startswith(" "):
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
m = re.match(r"(?P<name>\w+) \((?P<type>.*?)(?P<opt>, optional)?\):", line)
|
||||
if m is None:
|
||||
continue
|
||||
|
||||
args.append(
|
||||
ArgDoc(
|
||||
name=m.group("name"),
|
||||
type=m.group("type"),
|
||||
optional=m.group("opt") is not None,
|
||||
)
|
||||
)
|
||||
|
||||
return args
|
||||
|
||||
def log(self, node: ast.FunctionDef, msg: str):
|
||||
loc: str = f"{node.name} L{node.lineno}:{node.col_offset+1}"
|
||||
print(f" ({loc}) {msg}")
|
||||
|
||||
def _is_ignored(self, node: ast.FunctionDef) -> bool:
|
||||
name: str = node.name
|
||||
if name.startswith("visit_") or name.startswith("_visit_"):
|
||||
return True
|
||||
if name.startswith("parse_") or name.startswith("_parse_"):
|
||||
return True
|
||||
if name.startswith("_print"):
|
||||
return True
|
||||
if name.startswith("_write"):
|
||||
return True
|
||||
if name.startswith("__") and name.endswith("__"):
|
||||
return True
|
||||
if name == "accept":
|
||||
return True
|
||||
node.decorator_list
|
||||
match node:
|
||||
case ast.FunctionDef(
|
||||
decorator_list=[
|
||||
ast.Call(
|
||||
func=ast.Name(id="method"),
|
||||
),
|
||||
],
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
|
||||
docstring: Optional[str] = ast.get_docstring(node)
|
||||
func_name: str = node.name
|
||||
if docstring is None:
|
||||
if not self._is_ignored(node):
|
||||
self.log(node, f"Missing docstring for function {func_name}")
|
||||
return
|
||||
|
||||
args_doc: list[ArgDoc] = self._get_args(docstring)
|
||||
by_name: dict[str, ArgDoc] = {}
|
||||
for doc in args_doc:
|
||||
if doc.name in by_name:
|
||||
self.log(node, f"Multiple documentation lines for argument {doc.name}")
|
||||
by_name[doc.name] = doc
|
||||
|
||||
all_params: list[Param] = []
|
||||
|
||||
pos_args: list[ast.arg] = node.args.posonlyargs
|
||||
mixed_args: list[ast.arg] = node.args.args
|
||||
kw_args: list[ast.arg] = node.args.kwonlyargs
|
||||
|
||||
def add_param(arg: ast.arg, optional: bool):
|
||||
all_params.append(
|
||||
Param(
|
||||
name=arg.arg,
|
||||
annotation=(
|
||||
ast.unparse(arg.annotation)
|
||||
if arg.annotation is not None
|
||||
else None
|
||||
),
|
||||
optional=optional,
|
||||
)
|
||||
)
|
||||
|
||||
n_pos: int = len(pos_args) + len(mixed_args)
|
||||
for i, arg in enumerate(pos_args):
|
||||
j: int = n_pos - i - 1
|
||||
optional: bool = j < len(node.args.defaults)
|
||||
add_param(arg, optional)
|
||||
|
||||
for i, arg in enumerate(mixed_args):
|
||||
j: int = len(mixed_args) - i - 1
|
||||
optional: bool = j < len(node.args.defaults)
|
||||
add_param(arg, optional)
|
||||
|
||||
for arg, default in zip(kw_args, node.args.kw_defaults):
|
||||
optional: bool = default is not None
|
||||
add_param(arg, optional)
|
||||
|
||||
for param in all_params:
|
||||
doc: Optional[ArgDoc] = by_name.get(param.name, None)
|
||||
if doc is None:
|
||||
if param.name not in {"self", "cls"}:
|
||||
self.log(
|
||||
node, f"Missing documentation for parameter '{param.name}'"
|
||||
)
|
||||
continue
|
||||
|
||||
if doc.name != param.name:
|
||||
self.log(node, f"Documentation mismatch for '{param.name}': wrong name")
|
||||
|
||||
if doc.type != param.annotation:
|
||||
self.log(node, f"Documentation mismatch for '{param.name}': wrong type")
|
||||
|
||||
if doc.optional != param.optional:
|
||||
self.log(
|
||||
node,
|
||||
f"Documentation mismatch for '{param.name}': wrong optionality",
|
||||
)
|
||||
|
||||
|
||||
def check_file(path: Path):
|
||||
source: str = path.read_text()
|
||||
tree = ast.parse(source)
|
||||
checker = Checker()
|
||||
checker.visit(tree)
|
||||
|
||||
|
||||
def main():
|
||||
folder: Path = (Path(__file__).parent.parent / "midas").resolve()
|
||||
all_files = folder.rglob("*.py")
|
||||
for f in all_files:
|
||||
print(f.relative_to(folder))
|
||||
check_file(f)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user