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 == 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> === `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. 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. 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. 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( set document(
title: title, title: title,
author: author, author: author,
date: none,
) )
set text( set text(
font: "Source Sans 3", 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): 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()) any = reg.define_type("Any", TopType())
unit = reg.define_type("None", UnitType()) unit = reg.define_type("None", UnitType())
object = reg.define_type("object", BaseType(name="object")) 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") self.logger: logging.Logger = logging.getLogger("CallDispatcher")
def set_reporter(self, reporter: FileReporter): def set_reporter(self, reporter: FileReporter):
"""Set the current reporter
Args:
reporter (FileReporter): the new file reporter
"""
self.reporter = reporter self.reporter = reporter
def get_result( def get_result(
@@ -123,8 +128,8 @@ class CallDispatcher(Generic[E]):
Args: Args:
location (Location): the call location location (Location): the call location
callee (Type): the called function callee (Type): the called function
positional (list[TypedExpr]): the list of positional arguments positional (list[TypedExpr[E]]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword 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. report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns: Returns:
@@ -250,7 +255,7 @@ class CallDispatcher(Generic[E]):
"""Check whether the passed argument types correspond to their matched parameter definitions """Check whether the passed argument types correspond to their matched parameter definitions
Args: 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. report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns: Returns:
@@ -286,8 +291,8 @@ class CallDispatcher(Generic[E]):
Args: Args:
overloads (list[Type]): the list of possible overloads overloads (list[Type]): the list of possible overloads
location (Location): the call location location (Location): the call location
positional (list[TypedExpr]): the list of positional arguments positional (list[TypedExpr[E]]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keywords 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. report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns: Returns:
@@ -385,8 +390,8 @@ class CallDispatcher(Generic[E]):
Args: Args:
function (Function): the function definition function (Function): the function definition
location (Location): the call location location (Location): the call location
positional (list[TypedExpr]): the list of positional arguments positional (list[TypedExpr[E]]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword 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. report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns: Returns:
@@ -514,8 +519,8 @@ class CallDispatcher(Generic[E]):
function / a subtype of another. function / a subtype of another.
Args: Args:
mapped1 (list[MappedArgument]): the first argument mappings (subtype) mapped1 (list[MappedArgument[E]]): the first argument mappings (subtype)
mapped2 (list[MappedArgument]): the second argument mappings (supertype) mapped2 (list[MappedArgument[E]]): the second argument mappings (supertype)
Returns: Returns:
bool: `True` if `mapped1` is a subtype of `mapped2`, `False` otherwise 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 """Evaluate a predicate function call
Args: Args:
location (Location): the location of the call expression
predicate (Predicate): the predicate to evaluate predicate (Predicate): the predicate to evaluate
args (list[Any]): a list of positional arguments args (list[Any]): a list of positional arguments
kwargs (dict[str, Any]): a map of keyword 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 is set in the context using :func:`set_value` with the parameter's name
Args: Args:
location (Location): the location of the call expression
function (Function): the called function function (Function): the called function
args (list[Any]): a list of positional arguments args (list[Any]): a list of positional arguments
kwargs (dict[str, Any]): a map of keyword arguments kwargs (dict[str, Any]): a map of keyword arguments
+1 -1
View File
@@ -45,7 +45,7 @@ class ColumnManager:
Args: Args:
reporter (FileReporter): the file reporter to use for diagnostics reporter (FileReporter): the file reporter to use for diagnostics
location (Location): the subscript's location location (Location): the subscript's location
column (DataFrameType): the column type column (ColumnType): the column type
index (TypedExpr): the index index (TypedExpr): the index
Returns: Returns:
+6 -4
View File
@@ -353,10 +353,12 @@ class ColumnMethodRegistry(MethodRegistry[Call]):
call (Call): the call object call (Call): the call object
kwargs (list[Function.Parameter], optional): a list of extra kwargs (list[Function.Parameter], optional): a list of extra
keyword-only parameters. Defaults to []. keyword-only parameters. Defaults to [].
formula (Callable[[Type], Formula], optional): optional formula formula (Optional[Callable[[Type], Formula]], optional):
builder function to compute the return type. If set, the function optional formula builder function to compute the return type.<br>
should accept the inner column type and return a formula. If set, the function should accept the inner column type and
If `None`, the result is typed as `Column[Any]`. Defaults to None. return a formula.<br>
If `None`, the result is typed as `Column[Any]`.
Defaults to None.
Returns: Returns:
Type: the result type Type: the result type
+27 -9
View File
@@ -486,11 +486,10 @@ class PythonTyper(
self._assign_sub(location, var, index, value_type) self._assign_sub(location, var, index, value_type)
case _: case _:
if not isinstance(target, p.VariableExpr): self.logger.warning(f"Unsupported assignment to {target}")
self.logger.warning(f"Unsupported assignment to {target}") self.reporter.warning(
self.reporter.warning( target.location, f"Unsupported assignment to {target}"
target.location, f"Unsupported assignment to {target}" )
)
def _assign_var(self, location: Location, target: p.VariableExpr, value_type: Type): def _assign_var(self, location: Location, target: p.VariableExpr, value_type: Type):
"""Type check assignment to the given target """Type check assignment to the given target
@@ -518,11 +517,12 @@ class PythonTyper(
def _assign_attr( def _assign_attr(
self, location: Location, object: p.Expr, name: str, value_type: Type 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: Args:
location (Location): the location of the assignment 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 value_type (Type): the value to be assigned
""" """
object_type: Type = self.type_of(object) object_type: Type = self.type_of(object)
@@ -544,11 +544,15 @@ class PythonTyper(
index: p.Expr, index: p.Expr,
value_type: Type, value_type: Type,
): ):
"""Type check assignment to the given target """Type check assignment to the given subscript target
Args: Args:
location (Location): the location of the assignment 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 value_type (Type): the value to be assigned
""" """
var_type: Type = self.type_of(var) var_type: Type = self.type_of(var)
@@ -690,6 +694,20 @@ class PythonTyper(
right: TypedExpr, right: TypedExpr,
method: str, method: str,
) -> Type: ) -> 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: try:
return self.call_method( return self.call_method(
location=location, location=location,
+5
View File
@@ -70,6 +70,11 @@ class FileReporter:
@contextmanager @contextmanager
def with_context(self, ctx: str): 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) self._context.append(ctx)
try: try:
yield 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()