4 Commits
Author SHA1 Message Date
HEL c3b243288d fix(checker): remove redundant instance check 2026-07-09 15:59:55 +02:00
HEL cd9b80d22b docs: fix some typos in manual 2026-07-09 15:59:29 +02:00
HEL 21b648e18f docs: fix some docstrings 2026-07-09 15:56:56 +02:00
HEL 742693fa38 chore: add script to check docstrings 2026-07-09 15:55:06 +02:00
11 changed files with 1081 additions and 833 deletions
+844 -807
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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.
+1
View File
@@ -61,6 +61,7 @@
set document(
title: title,
author: author,
date: none,
)
set text(
font: "Source Sans 3",
+5 -1
View File
@@ -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"))
+14 -9
View File
@@ -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
+2
View File
@@ -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
+1 -1
View File
@@ -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:
+6 -4
View File
@@ -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
View File
@@ -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,
+5
View File
@@ -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
+174
View File
@@ -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()