feat(cli): generate stubs in build dir when compiling

This commit is contained in:
2026-06-30 11:19:17 +02:00
parent d14f208897
commit ffccc1bedd
4 changed files with 50 additions and 15 deletions

View File

@@ -5,7 +5,7 @@
import sys import sys
from pathlib import Path from pathlib import Path
from typing import TextIO from typing import Optional, TextIO
import click import click
@@ -19,18 +19,23 @@ from midas.utils import TypedAST
@click.command(help="Compile source") @click.command(help="Compile source")
@click.argument("file", type=click.File("r")) @click.argument("file", type=click.File("r"))
@click.option("-t", "--types", type=click.File("r"), multiple=True) @click.option("-t", "--types", type=click.File("r"), multiple=True)
@click.option("-s", "--stubs", type=str, multiple=True)
@click.option("--ignore-errors", is_flag=True) @click.option("--ignore-errors", is_flag=True)
def compile( def compile(
file: TextIO, file: TextIO,
types: tuple[TextIO], types: tuple[TextIO],
stubs: tuple[str],
ignore_errors: bool, ignore_errors: bool,
): ):
source: str = file.read() source: str = file.read()
source_path: Path = Path(file.name).resolve() source_path: Path = Path(file.name).resolve()
checker = TypeChecker() checker = TypeChecker()
for types_file in types: type_files: list[tuple[Path, Optional[str]]] = []
checker.import_midas(Path(types_file.name).resolve()) for i, types_file in enumerate(types):
in_path: Path = Path(types_file.name).resolve()
checker.import_midas(in_path)
type_files.append((in_path, stubs[i] if i < len(stubs) else None))
typed_ast: TypedAST = checker.type_check_source(source, str(source_path)) typed_ast: TypedAST = checker.type_check_source(source, str(source_path))
diagnostics: list[Diagnostic] = checker.diagnostics.copy() diagnostics: list[Diagnostic] = checker.diagnostics.copy()
@@ -43,4 +48,4 @@ def compile(
sys.exit(1) sys.exit(1)
generator = Generator(workdir=source_path.parent, types=checker.types) generator = Generator(workdir=source_path.parent, types=checker.types)
generator.generate(typed_ast, source_path) generator.generate(typed_ast, source_path, type_files=type_files)

View File

@@ -1,7 +1,7 @@
import ast import ast
import time import time
from pathlib import Path from pathlib import Path
from typing import TextIO from typing import Optional, TextIO
import black import black
import click import click
@@ -38,15 +38,17 @@ class Handler(FileSystemEventHandler):
@click.command(help="Generate stubs from Midas definitions") @click.command(help="Generate stubs from Midas definitions")
@click.argument("file", type=click.File("r")) @click.argument("file", type=click.File("r"))
@click.option("-o", "--output", type=click.File("w"), default="-") @click.option("-o", "--output", type=click.File("w"))
@click.option("-w", "--watch", is_flag=True) @click.option("-w", "--watch", is_flag=True)
def stubs( def stubs(
file: TextIO, file: TextIO,
output: TextIO, output: Optional[TextIO],
watch: bool, watch: bool,
): ):
source_path: Path = Path(file.name).resolve() source_path: Path = Path(file.name).resolve()
out_path: Path = Path(output.name).resolve() out_path: Path = source_path.with_suffix(".pyi")
if output is not None:
out_path = Path(output.name).resolve()
generate_stubs(source_path, out_path) generate_stubs(source_path, out_path)
if watch: if watch:

View File

@@ -9,6 +9,7 @@ import midas.ast.midas as m
import midas.ast.python as p import midas.ast.python as p
from midas.ast.location import Location from midas.ast.location import Location
from midas.ast.printer import MidasPrinter from midas.ast.printer import MidasPrinter
from midas.checker.checker import TypeChecker
from midas.checker.registry import TypesRegistry from midas.checker.registry import TypesRegistry
from midas.checker.types import ( from midas.checker.types import (
AppliedType, AppliedType,
@@ -30,6 +31,7 @@ from midas.checker.types import (
UnknownType, UnknownType,
) )
from midas.generator.constraints import ConstraintGenerator from midas.generator.constraints import ConstraintGenerator
from midas.generator.stubs import StubsGenerator
from midas.utils import TypedAST from midas.utils import TypedAST
@@ -64,8 +66,10 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
self.define_is_dataframe: bool = False self.define_is_dataframe: bool = False
self.define_is_column: bool = False self.define_is_column: bool = False
def generate_ast(self, typed_ast: TypedAST, src_path: Path) -> ast.AST: def set_src_path(self, path: Path):
self.rel_src_path = src_path.resolve().relative_to(self.workdir) self.rel_src_path = path.resolve().relative_to(self.workdir)
def generate_ast(self, typed_ast: TypedAST) -> ast.AST:
self._typed_ast = typed_ast self._typed_ast = typed_ast
body: list[ast.stmt] = self._visit_body(typed_ast.stmts) body: list[ast.stmt] = self._visit_body(typed_ast.stmts)
predicates: list[ast.stmt] = self._constraint_generator.get_definitions() predicates: list[ast.stmt] = self._constraint_generator.get_definitions()
@@ -83,10 +87,13 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
return module return module
def generate( def generate(
self, typed_ast: TypedAST, src_path: Path, out_path: Optional[Path] = None self,
typed_ast: TypedAST,
src_path: Path,
out_path: Optional[Path] = None,
type_files: Optional[list[tuple[Path, Optional[str]]]] = None,
) -> Path: ) -> Path:
module: ast.AST = self.generate_ast(typed_ast, src_path) self.set_src_path(src_path)
compiled: str = ast.unparse(module)
if out_path is None: if out_path is None:
if self.build_dir.exists(): if self.build_dir.exists():
shutil.rmtree(self.build_dir) shutil.rmtree(self.build_dir)
@@ -98,10 +105,30 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
raise ValueError( raise ValueError(
f"Directory traversal, {self.rel_src_path} points outside of parent directory" f"Directory traversal, {self.rel_src_path} points outside of parent directory"
) )
out_path.parent.mkdir(parents=True, exist_ok=True) out_dir: Path = out_path.parent
out_dir.parent.mkdir(parents=True, exist_ok=True)
if type_files is not None:
for in_path, out_name in type_files:
if out_name is None:
out_name = in_path.stem
self.generate_stubs(in_path, out_dir / f"{out_name}.py")
module: ast.AST = self.generate_ast(typed_ast)
compiled: str = ast.unparse(module)
out_path.write_text(compiled) out_path.write_text(compiled)
return out_path return out_path
def generate_stubs(self, in_path: Path, out_path: Path):
checker = TypeChecker()
checker.import_midas(in_path)
generator = StubsGenerator(checker.types)
module: ast.Module = generator.generate_stubs()
module = ast.fix_missing_locations(module)
output: str = ast.unparse(module)
out_path.write_text(output)
def visit_binary_expr(self, expr: p.BinaryExpr) -> ast.expr: def visit_binary_expr(self, expr: p.BinaryExpr) -> ast.expr:
return ast.BinOp( return ast.BinOp(
left=expr.left.accept(self), left=expr.left.accept(self),

View File

@@ -46,7 +46,8 @@ class GeneratorTester(Tester):
if not any(d.type == DiagnosticType.ERROR for d in checker.diagnostics): if not any(d.type == DiagnosticType.ERROR for d in checker.diagnostics):
generator = Generator(workdir=path.parent, types=checker.types) generator = Generator(workdir=path.parent, types=checker.types)
result.compiled_ast = generator.generate_ast(typed_ast, path) generator.set_src_path(path)
result.compiled_ast = generator.generate_ast(typed_ast)
return result return result