blob: e7ce60938a361c870e63e591ed7f7ad66302777e [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Utilities for parsing and generating stub files for TVM FFI."""
from __future__ import annotations
import dataclasses
import difflib
import os
import traceback
from pathlib import Path
from typing import Callable, Generator, Iterable
from . import consts as C
@dataclasses.dataclass
class CodeBlock:
"""A block of code to be generated in a stub file."""
kind: C.STUB_BLOCK_KINDS
param: str | tuple[str, ...]
lineno_start: int
lineno_end: int | None
lines: list[str]
def __post_init__(self) -> None:
"""Validate the code block after initialization."""
assert self.kind in {
"global",
"object",
"ty-map",
"import-section",
"import-object",
"export",
"__all__",
None,
}
@property
def indent(self) -> int:
"""Calculate the indentation level of the block based on the first line."""
if not self.lines:
return 0
first_line = self.lines[0]
return len(first_line) - len(first_line.lstrip(" "))
@staticmethod
def from_begin_line(lineo: int, line: str) -> CodeBlock:
"""Parse a line to create a CodeBlock if it contains a stub begin marker."""
if line.startswith(C.STUB_TY_MAP):
line = line[len(C.STUB_TY_MAP) :].strip()
return CodeBlock(
kind="ty-map",
param=line,
lineno_start=lineo,
lineno_end=lineo,
lines=[],
)
elif line.startswith(C.STUB_IMPORT_OBJECT):
line = line[len(C.STUB_IMPORT_OBJECT) :].strip()
splits = [p.strip() for p in line.split(";")]
if len(splits) < 3:
splits += [""] * (3 - len(splits))
return CodeBlock(
kind="import-object",
param=tuple(splits),
lineno_start=lineo,
lineno_end=lineo,
lines=[],
)
assert line.startswith(C.STUB_BEGIN)
param: str | tuple[str, ...]
stub = line[len(C.STUB_BEGIN) :].strip()
if stub.startswith("global/"):
kind = "global"
param = stub[len("global/") :].strip()
if "@" in param:
param = tuple(param.split("@"))
else:
param = (param, "")
elif stub.startswith("object/"):
kind = "object"
param = stub[len("object/") :].strip()
elif stub.startswith("ty-map/"):
kind = "ty-map"
param = stub[len("ty-map/") :].strip()
elif stub == "import-section":
kind = "import-section"
param = ""
elif stub.startswith("export/"):
kind = "export"
param = stub[len("export/") :].strip()
elif stub == "__all__":
kind = "__all__"
param = ""
else:
raise ValueError(f"Unknown stub type `{stub}` at line {lineo}")
return CodeBlock(
kind=kind,
param=param,
lineno_start=lineo,
lineno_end=None,
lines=[],
)
@dataclasses.dataclass
class FileInfo:
"""Information about a file being processed."""
path: Path
lines: tuple[str, ...]
code_blocks: list[CodeBlock]
def update(self, verbose: bool, dry_run: bool) -> bool:
"""Update the file's lines based on the current code blocks and optionally show a diff."""
new_lines = tuple(line for block in self.code_blocks for line in block.lines)
if self.lines == new_lines:
if verbose:
print(f"{C.TERM_CYAN}-----> Unchanged{C.TERM_RESET}")
return False
if verbose:
for line in difflib.unified_diff(self.lines, new_lines, lineterm=""):
# Skip placeholder headers when fromfile/tofile are unspecified
if line.startswith("---") or line.startswith("+++"):
continue
if line.startswith("-") and not line.startswith("---"):
print(f"{C.TERM_RED}{line}{C.TERM_RESET}") # Red for removals
elif line.startswith("+") and not line.startswith("+++"):
print(f"{C.TERM_GREEN}{line}{C.TERM_RESET}") # Green for additions
elif line.startswith("?"):
print(f"{C.TERM_YELLOW}{line}{C.TERM_RESET}") # Yellow for hints
else:
print(line)
self.lines = new_lines
if not dry_run:
self.path.write_text("\n".join(self.lines) + "\n", encoding="utf-8")
return True
@staticmethod
def from_file(file: Path, include_empty: bool = False) -> FileInfo | None: # noqa: PLR0912
"""Parse a file to extract code blocks based on stub markers."""
assert file.is_file(), f"Expected a file, but got: {file}"
file = file.resolve()
has_marker = False
lines: list[str] = file.read_text(encoding="utf-8").splitlines()
for _, line in enumerate(lines, start=1):
if line.strip().startswith(C.STUB_SKIP_FILE):
return None
if line.strip().startswith(C.STUB_PREFIX):
has_marker = True
if not has_marker and not include_empty:
return None
del has_marker
codes: list[CodeBlock] = []
code: CodeBlock | None = None
for lineno, line in enumerate(lines, 1):
clean_line = line.strip()
if clean_line.startswith(C.STUB_BEGIN):
# Process "# tvm-ffi-stubgen(begin)"
if code is not None:
raise ValueError(f"Nested stub not permitted at line {lineno}")
code = CodeBlock.from_begin_line(lineno, clean_line)
code.lineno_start = lineno
code.lines.append(line)
elif clean_line.startswith(C.STUB_END):
# Process "# tvm-ffi-stubgen(end)"
if code is None:
raise ValueError(f"Unmatched `{C.STUB_END}` found at line {lineno}")
code.lineno_end = lineno
code.lines.append(line)
codes.append(code)
code = None
elif clean_line.startswith(C.STUB_TY_MAP):
# Process "# tvm-ffi-stubgen(ty_map)"
ty_code = CodeBlock.from_begin_line(lineno, clean_line)
ty_code.lineno_end = lineno
ty_code.lines.append(line)
codes.append(ty_code)
del ty_code
elif clean_line.startswith(C.STUB_IMPORT_OBJECT):
# Process "# tvm-ffi-stubgen(import-object)"
imp_code = CodeBlock.from_begin_line(lineno, clean_line)
imp_code.lineno_end = lineno
imp_code.lines.append(line)
codes.append(imp_code)
del imp_code
elif clean_line.startswith(C.STUB_PREFIX):
raise ValueError(f"Unknown stub type at line {lineno}: {clean_line}")
elif code is None:
# Process a plain line outside of any stub block
codes.append(
CodeBlock(
kind=None,
param="",
lineno_start=lineno,
lineno_end=lineno,
lines=[line],
)
)
else: # Process a line inside a stub block
code.lines.append(line)
if code is not None:
raise ValueError("Unclosed stub block at end of file")
return FileInfo(path=file, lines=tuple(lines), code_blocks=codes)
def reload(self) -> None:
"""Reload the code blocks from disk while preserving original `lines`."""
source = FileInfo.from_file(self.path)
assert source is not None, f"File no longer exists or valid: {self.path}"
self.code_blocks = source.code_blocks
def collect_files(paths: list[Path]) -> list[FileInfo]:
"""Collect all files from the given paths and parse them into FileInfo objects."""
def _on_error(e: Exception) -> None:
print(
f"{C.TERM_RED}[Error]\n{traceback.format_exc()}{C.TERM_RESET}",
end="",
flush=True,
)
def _walk_recursive() -> Generator[Path, None, None]:
for p in paths:
if p.is_file():
yield p
continue
for root, _dirs, files in path_walk(p, follow_symlinks=False, on_error=_on_error):
for file in files:
f = Path(root) / file
if f.suffix.lower() not in C.DEFAULT_SOURCE_EXTS:
continue
yield f
filenames = list(_walk_recursive())
filenames = sorted(filenames, key=lambda f: str(f))
files = []
for file in filenames:
try:
content = FileInfo.from_file(file)
except Exception as e:
_on_error(e)
else:
if content is not None:
files.append(content)
return files
def path_walk(
p: Path,
*,
top_down: bool = True,
on_error: Callable[[Exception], None] | None = None,
follow_symlinks: bool = False,
) -> Iterable[tuple[Path, list[str], list[str]]]:
"""Compat wrapper for Path.walk (3.12+) with a fallback for < 3.12."""
if not p.exists():
return
# Python 3.12+ - just delegate to `Path.walk`
if hasattr(p, "walk"):
yield from p.walk( # ty: ignore[call-non-callable]
top_down=top_down,
on_error=on_error,
follow_symlinks=follow_symlinks,
)
return
# Python < 3.12 - use `os.walk``
for root_str, dirnames, filenames in os.walk(
p,
topdown=top_down,
onerror=on_error,
followlinks=follow_symlinks,
):
root = Path(root_str)
# dirnames and filenames are lists of *names*, not full paths,
# just like Path.walk()'s documented behavior.
yield root, dirnames, filenames