From 085e7aa7d6dcf898a80c5def96017504dcaa1d45 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Fri, 30 Jan 2026 04:51:45 +0000 Subject: [PATCH] Initial commit: Add python-stub-generator project --- stubgen/cli.py | 304 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 stubgen/cli.py diff --git a/stubgen/cli.py b/stubgen/cli.py new file mode 100644 index 0000000..17577eb --- /dev/null +++ b/stubgen/cli.py @@ -0,0 +1,304 @@ +"""Command-line interface for stubgen.""" + +import sys +from pathlib import Path + +import click + +from stubgen import __version__ +from stubgen.config import load_config, should_exclude +from stubgen.generator import StubGenerator +from stubgen.inferrer import Inferrer + + +@click.group() +@click.version_option(version=__version__, prog_name="stubgen") +@click.option( + "--verbose", "-v", + is_flag=True, + help="Enable verbose output" +) +@click.option( + "--config", "-c", + type=click.Path(exists=True, dir_okay=False), + help="Path to configuration file" +) +@click.pass_context +def main(ctx: click.Context, verbose: bool, config: Path): + """Generate Python type stub files (.pyi) from untyped Python code.""" + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + ctx.obj["config"] = config + + +@main.command() +@click.argument( + "input_path", + type=click.Path(exists=True, file_okay=True, dir_okay=True) +) +@click.option( + "--output", "-o", + type=click.Path(file_okay=False), + help="Output directory for generated stubs" +) +@click.option( + "--recursive", "-r", + is_flag=True, + default=True, + help="Recursively process directories (default: True)" +) +@click.option( + "--no-recursive", + is_flag=True, + help="Disable recursive directory processing" +) +@click.option( + "--exclude", "-e", + multiple=True, + help="Patterns to exclude (can be specified multiple times)" +) +@click.option( + "--dry-run", + is_flag=True, + help="Show what would be generated without writing files" +) +@click.option( + "--interactive", "-i", + is_flag=True, + help="Prompt for confirmation on inferred types" +) +@click.option( + "--infer-depth", + type=int, + default=3, + help="Depth of type inference (default: 3)" +) +@click.option( + "--verbose", "-v", + is_flag=True, + help="Enable verbose output" +) +@click.pass_context +def stubgen( + ctx: click.Context, + input_path: Path, + output: Path, + recursive: bool, + no_recursive: bool, + exclude: tuple, + dry_run: bool, + interactive: bool, + infer_depth: int, + verbose: bool +): + """Generate type stubs for Python files. + + INPUT_PATH can be a Python file or a directory. + """ + ctx.ensure_object(dict) + config_path = ctx.obj.get("config") + + do_recursive = not no_recursive + is_dir = Path(input_path).is_dir() + if is_dir and no_recursive: + do_recursive = False + + exclude_patterns = list(exclude) + + config = None + if config_path: + config = load_config(config_path) + if verbose: + click.echo(f"Loaded config from {config_path}") + + if config: + config_excludes = config.get("exclude_patterns", []) + exclude_patterns.extend(config_excludes) + + if output is None and config.get("output_dir"): + output = Path(config.get("output_dir")) + + if infer_depth == 3 and config.get("infer_depth"): + infer_depth = config.get("infer_depth") + + if not interactive and config.get("interactive"): + interactive = True + + input_path = Path(input_path) + + if not input_path.exists(): + click.echo(f"Error: Path does not exist: {input_path}", err=True) + sys.exit(1) + + if not is_dir: + input_path = input_path.resolve() + + generator = StubGenerator(inferrer=Inferrer(infer_depth=infer_depth)) + + try: + if input_path.is_file() and input_path.suffix == ".py": + if should_exclude(input_path, exclude_patterns): + if verbose: + click.echo(f"Excluded: {input_path}") + return + + if verbose: + click.echo(f"Processing: {input_path}") + + output_path = None + if output is not None: + output_path = Path(output) / input_path.with_suffix(".pyi").name + else: + output_path = input_path.with_suffix(".pyi") + + if dry_run: + from stubgen.parser import FileParser + file_info = FileParser(input_path).parse() + stub_content = generator.writer.generate(file_info) + click.echo(f"Would generate: {output_path}") + click.echo("---") + click.echo(stub_content) + click.echo("---") + else: + result = generator.generate_file(input_path, output_path) + if verbose: + click.echo(f"Generated: {result}") + + elif input_path.is_dir(): + output_dir = output or input_path + + py_files = [] + if do_recursive: + py_files = list(input_path.rglob("*.py")) + else: + py_files = list(input_path.glob("*.py")) + + py_files = [f for f in py_files if not should_exclude(f, exclude_patterns)] + py_files = sorted(py_files) + + if verbose: + click.echo(f"Found {len(py_files)} Python files") + + for py_file in py_files: + if verbose: + click.echo(f"Processing: {py_file}") + + rel_path = py_file.relative_to(input_path) + output_path = output_dir / rel_path.with_suffix(".pyi") + + if dry_run: + from stubgen.parser import FileParser + file_info = FileParser(py_file).parse() + stub_content = generator.writer.generate(file_info) + click.echo(f"Would generate: {output_path}") + else: + generator.generate_file(py_file, output_path) + if verbose: + click.echo(f"Generated: {output_path}") + + if dry_run: + click.echo(f"Would generate {len(py_files)} stub files in {output_dir}") + + else: + click.echo(f"Error: {input_path} is not a Python file or directory", err=True) + sys.exit(1) + + except Exception as e: + click.echo(f"Error: {e}", err=True) + if verbose: + import traceback + traceback.print_exc() + sys.exit(1) + + +@main.command() +@click.option( + "--output", "-o", + type=click.Path(file_okay=False), + help="Output directory for generated stubs" +) +@click.argument("files", nargs=-1, type=click.Path(exists=True)) +@click.pass_context +def generate( + ctx: click.Context, + output: Path, + files: tuple +): + """Generate type stubs for specified files.""" + ctx.ensure_object(dict) + if not files: + click.echo("Error: No files specified", err=True) + sys.exit(1) + + verbose = ctx.obj.get("verbose", False) + + generator = StubGenerator() + + output_dir = output or Path.cwd() + + for file_path in files: + file_path = Path(file_path) + + if file_path.suffix != ".py": + continue + + if verbose: + click.echo(f"Processing: {file_path}") + + output_path = output_dir / file_path.with_suffix(".pyi").name + + try: + result = generator.generate_file(file_path, output_path) + if verbose: + click.echo(f"Generated: {result}") + except Exception as e: + click.echo(f"Error processing {file_path}: {e}", err=True) + + +@main.command("init-config") +@click.option( + "--path", "-p", + type=click.Path(dir_okay=False), + default="stubgen.toml", + help="Path for the config file" +) +def init_config(path: Path): + """Generate a stubgen configuration file.""" + config_content = '''# stubgen Configuration File + +[tool.stubgen] +# Patterns to exclude from processing +exclude_patterns = [ + "tests/*", + "*/__pycache__/*", + "*/.venv/*", + "*/venv/*", + "*/node_modules/*", +] + +# Depth of type inference (default: 3) +infer_depth = 3 + +# Enable strict mode (fail on errors) +strict_mode = false + +# Enable interactive type confirmation +interactive = false + +# Default output directory +# output_dir = "./stubs" +''' + + path = Path(path) + + try: + with open(path, 'w') as f: + f.write(config_content) + click.echo(f"Generated configuration file: {path}") + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +if __name__ == "__main__": + main()