|
1 | 1 | #!/usr/bin/env python |
2 | 2 | import argparse |
3 | 3 | import ast |
| 4 | +import enum |
4 | 5 | import os |
5 | 6 | import subprocess |
6 | 7 | from pathlib import Path |
| 8 | +from typing import Any, Never |
7 | 9 |
|
8 | | -TEMPLATE: str = """\ |
| 10 | +import tomllib |
| 11 | +from packaging.requirements import Requirement |
| 12 | + |
| 13 | + |
| 14 | +class LazyLoader(enum.StrEnum): |
| 15 | + LAZY_LOADER = enum.auto() |
| 16 | + LIBLAF_LAZY_LOADER = enum.auto() |
| 17 | + |
| 18 | + |
| 19 | +TEMPLATES: dict[LazyLoader, str] = { |
| 20 | + LazyLoader.LAZY_LOADER: """\ |
| 21 | +from lazy_loader import attach_stub |
| 22 | +
|
| 23 | +__getattr__, __dir__, __all__ = attach_stub(__name__, __file__) |
| 24 | +
|
| 25 | +del attach_stub |
| 26 | +""", |
| 27 | + LazyLoader.LIBLAF_LAZY_LOADER: """\ |
9 | 28 | from liblaf.lazy_loader import attach_stub |
10 | 29 |
|
11 | | -__getattr__, __dir__, __all__ = attach_stub(__name__, __package__, __file__) |
| 30 | +__getattr__, __dir__, __all__ = attach_stub(__name__, __file__, __package__) |
12 | 31 |
|
13 | 32 | del attach_stub |
14 | | -""" |
| 33 | +""", |
| 34 | +} |
15 | 35 |
|
16 | 36 |
|
17 | 37 | class Args(argparse.Namespace): |
18 | 38 | path: Path |
19 | 39 |
|
20 | 40 |
|
21 | | -def parse_args() -> Args: |
22 | | - parser: argparse.ArgumentParser = argparse.ArgumentParser() |
23 | | - parser.add_argument("path", nargs="?", type=Path) |
24 | | - args: argparse.Namespace = parser.parse_args(namespace=Args()) |
25 | | - if args.path is None: |
26 | | - if (path := os.getenv("MISE_PROJECT_ROOT")) is not None: |
27 | | - args.path = Path(path) |
28 | | - else: |
29 | | - args.path = Path.cwd() |
30 | | - return args |
| 41 | +def detect_lazy_loader(project_root: Path) -> LazyLoader: |
| 42 | + pyproject: Path = project_root / "pyproject.toml" |
| 43 | + with pyproject.open("rb") as fp: |
| 44 | + data: dict[str, Any] = tomllib.load(fp) |
| 45 | + project: Any = data.get("project") |
| 46 | + if not isinstance(project, dict): |
| 47 | + error(f"`project` table not found in: {pyproject}") |
| 48 | + dependencies: Any = project.get("dependencies") |
| 49 | + if not isinstance(dependencies, list): |
| 50 | + error(f"`project.dependencies` not found in: {pyproject}") |
| 51 | + dependency_names: set[str] = set() |
| 52 | + for dep in dependencies: |
| 53 | + req: Requirement = Requirement(dep) |
| 54 | + dependency_names.add(req.name.lower()) |
| 55 | + if "liblaf-lazy-loader" in dependency_names: |
| 56 | + return LazyLoader.LIBLAF_LAZY_LOADER |
| 57 | + if "lazy-loader" in dependency_names: |
| 58 | + return LazyLoader.LAZY_LOADER |
| 59 | + error( |
| 60 | + "no supported lazy loader found in project dependencies: " |
| 61 | + "expected `liblaf-lazy-loader` or `lazy-loader`" |
| 62 | + ) |
| 63 | + |
| 64 | + |
| 65 | +def error(message: str) -> Never: |
| 66 | + raise SystemExit(message) |
| 67 | + |
| 68 | + |
| 69 | +def find_project_root(path: Path) -> Path: |
| 70 | + for candidate in [path, *path.parents]: |
| 71 | + if (candidate / "pyproject.toml").exists(): |
| 72 | + return candidate |
| 73 | + error(f"pyproject.toml not found for: {path}") |
31 | 74 |
|
32 | 75 |
|
33 | | -def git_ls_files(path: Path) -> list[Path]: |
| 76 | +def get_docstring(file: Path) -> str | None: |
| 77 | + if not file.exists(): |
| 78 | + return None |
| 79 | + source: str = file.read_text() |
| 80 | + module: ast.Module = ast.parse(source) |
| 81 | + if not (module.body and isinstance(module.body[0], ast.Expr)): |
| 82 | + return None |
| 83 | + node: ast.expr = module.body[0].value |
| 84 | + if isinstance(node, ast.Constant) and isinstance(node.value, str): |
| 85 | + return ast.get_source_segment(source, node) |
| 86 | + return None |
| 87 | + |
| 88 | + |
| 89 | +def git_ls_files(*, project_root: Path, path: Path) -> list[Path]: |
| 90 | + try: |
| 91 | + relative_path: Path = path.relative_to(project_root) |
| 92 | + except ValueError: |
| 93 | + error(f"path is not under project root: {path}") |
| 94 | + |
34 | 95 | result: subprocess.CompletedProcess[str] = subprocess.run( |
35 | 96 | [ |
36 | 97 | "git", |
| 98 | + "-C", |
| 99 | + project_root, |
37 | 100 | "ls-files", |
38 | 101 | "--cached", |
39 | 102 | "--others", |
40 | 103 | "--exclude-standard", |
41 | 104 | "--", |
42 | | - f"{path}/**/__init__.pyi", |
| 105 | + relative_path / "__init__.pyi", |
| 106 | + relative_path / "**/__init__.pyi", |
43 | 107 | ], |
44 | 108 | stdout=subprocess.PIPE, |
45 | 109 | check=True, |
46 | 110 | text=True, |
47 | 111 | ) |
48 | | - return [Path(line) for line in result.stdout.splitlines() if line] |
| 112 | + return [project_root / line for line in result.stdout.splitlines() if line] |
49 | 113 |
|
50 | 114 |
|
51 | | -def get_docstring(file: Path) -> str | None: |
52 | | - if not file.exists(): |
53 | | - return None |
54 | | - source: str = file.read_text() |
55 | | - module: ast.Module = ast.parse(source) |
56 | | - if not (module.body and isinstance(module.body[0], ast.Expr)): |
57 | | - return None |
58 | | - node: ast.expr = module.body[0].value |
59 | | - if isinstance(node, ast.Constant) and isinstance(node.value, str): |
60 | | - return ast.get_source_segment(source, node) |
61 | | - return None |
| 115 | +def parse_args() -> Args: |
| 116 | + parser: argparse.ArgumentParser = argparse.ArgumentParser() |
| 117 | + parser.add_argument("path", nargs="?", type=Path) |
| 118 | + args: argparse.Namespace = parser.parse_args(namespace=Args()) |
| 119 | + if args.path is None: |
| 120 | + if (path := os.getenv("MISE_PROJECT_ROOT")) is not None: |
| 121 | + args.path = Path(path) |
| 122 | + else: |
| 123 | + args.path = Path.cwd() |
| 124 | + args.path = args.path.resolve() |
| 125 | + return args |
62 | 126 |
|
63 | 127 |
|
64 | | -def render_init(file: Path) -> str: |
| 128 | +def render_init(file: Path, *, loader: LazyLoader) -> str: |
65 | 129 | docstring: str | None = get_docstring(file) |
| 130 | + template: str = TEMPLATES[loader] |
66 | 131 | if docstring is None: |
67 | | - return TEMPLATE |
68 | | - return f"{docstring}\n\n{TEMPLATE}" |
| 132 | + return template |
| 133 | + return f"{docstring}\n\n{template}" |
69 | 134 |
|
70 | 135 |
|
71 | 136 | def main() -> None: |
72 | 137 | args: Args = parse_args() |
73 | | - for pyi in git_ls_files(args.path): |
| 138 | + project_root: Path = find_project_root(args.path) |
| 139 | + loader: LazyLoader = detect_lazy_loader(project_root) |
| 140 | + for pyi in git_ls_files(project_root=project_root, path=args.path): |
74 | 141 | py: Path = pyi.with_suffix(".py") |
75 | | - content: str = render_init(py) |
| 142 | + content: str = render_init(py, loader=loader) |
76 | 143 | if py.exists(): |
77 | 144 | if py.read_text() == content: |
78 | 145 | print("skipped:", py) |
|
0 commit comments