GFramework/scripts/generate-module-global-usings.py
GeWuYou b80f46b6fa feat(build): 添加 GFramework 模块化全局命名空间导入功能
- 在 NuGet 包中实现可选的 transitive global usings 功能
- 添加 XML 配置方式启用模块级自动命名空间导入
- 支持通过 GFrameworkExcludedUsing 排除特定命名空间
- 为所有运行时模块生成对应的 buildTransitive props 文件
- 添加 Python 脚本自动生成和验证命名空间配置
- 在文档中添加新的安装配置说明
- 创建单元测试验证生成脚本的同步状态
2026-03-24 21:46:31 +08:00

223 lines
7.1 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from dataclasses import dataclass
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = ROOT_DIR / "global-usings.modules.json"
AUTO_GENERATED_START = "<!-- <auto-generated gframework-transitive-global-usings> -->"
AUTO_GENERATED_END = "<!-- </auto-generated gframework-transitive-global-usings> -->"
@dataclass(frozen=True)
class ModuleConfig:
project_path: Path
namespaces: tuple[str, ...]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate optional transitive global usings for packable GFramework runtime modules.",
)
parser.add_argument(
"--check",
action="store_true",
help="Validate that generated files are up to date instead of writing them.",
)
return parser.parse_args()
def load_text(path: Path) -> tuple[str, str]:
raw = path.read_bytes()
if raw.startswith(b"\xef\xbb\xbf"):
return raw.decode("utf-8-sig"), "utf-8-sig"
return raw.decode("utf-8"), "utf-8"
def write_text(path: Path, content: str, encoding: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding=encoding)
def load_modules() -> list[ModuleConfig]:
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
modules: list[ModuleConfig] = []
for entry in data["modules"]:
project_path = ROOT_DIR / entry["project"]
namespaces = tuple(dict.fromkeys(entry["namespaces"]))
modules.append(ModuleConfig(project_path=project_path, namespaces=namespaces))
return modules
def discover_runtime_modules() -> list[Path]:
projects: list[Path] = []
for project_path in sorted(ROOT_DIR.rglob("*.csproj")):
if "obj" in project_path.parts or "bin" in project_path.parts:
continue
if project_path.name == "GFramework.csproj":
continue
project_name = project_path.stem
if not project_name.startswith("GFramework."):
continue
if ".Tests" in project_name or "SourceGenerators" in project_name:
continue
csproj_text, _ = load_text(project_path)
if re.search(r"<IsPackable>\s*false\s*</IsPackable>", csproj_text, re.IGNORECASE):
continue
projects.append(project_path)
return projects
def resolve_package_id(project_path: Path) -> str:
project_text, _ = load_text(project_path)
match = re.search(r"<PackageId>(.*?)</PackageId>", project_text, re.DOTALL)
if match is None:
return project_path.stem
package_id = match.group(1).strip()
return package_id.replace("$(AssemblyName)", project_path.stem)
def sanitize_identifier(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_]", "_", text)
def render_props(module: ModuleConfig) -> str:
item_name = f"_{sanitize_identifier(module.project_path.stem)}_TransitiveUsing"
lines = [
"<Project>",
" <!-- This file is generated by scripts/generate-module-global-usings.py. -->",
" <!-- EnableGFrameworkGlobalUsings=true enables the transitive global usings from this package. -->",
" <!-- Add <GFrameworkExcludedUsing Include=\"Namespace\" /> to opt out of specific namespaces. -->",
" <ItemGroup Condition=\"'$(EnableGFrameworkGlobalUsings)' == 'true'\">",
]
for namespace in module.namespaces:
lines.append(f" <{item_name} Include=\"{namespace}\" />")
lines.extend(
[
f" <{item_name} Remove=\"@(GFrameworkExcludedUsing)\" />",
f" <Using Include=\"@({item_name})\" />",
" </ItemGroup>",
"</Project>",
"",
],
)
return "\n".join(lines)
def render_pack_block(package_id: str) -> str:
props_path = f"buildTransitive\\{package_id}.props"
return "\n".join(
[
f" {AUTO_GENERATED_START}",
" <ItemGroup>",
f" <None Include=\"{props_path}\" Pack=\"true\" PackagePath=\"buildTransitive\" Visible=\"false\"/>",
" </ItemGroup>",
f" {AUTO_GENERATED_END}",
"",
],
)
def update_csproj(project_path: Path, package_id: str) -> tuple[str, str]:
project_text, encoding = load_text(project_path)
block = render_pack_block(package_id)
pattern = re.compile(
rf"\s*{re.escape(AUTO_GENERATED_START)}.*?{re.escape(AUTO_GENERATED_END)}\s*",
re.DOTALL,
)
if pattern.search(project_text):
updated = pattern.sub(lambda _: f"\n{block}", project_text)
else:
updated = project_text.replace("</Project>", f"{block}</Project>")
return updated, encoding
def validate_module_coverage(modules: list[ModuleConfig]) -> None:
configured_projects = {module.project_path.resolve() for module in modules}
discovered_projects = {project.resolve() for project in discover_runtime_modules()}
missing = sorted(project.relative_to(ROOT_DIR) for project in discovered_projects - configured_projects)
extra = sorted(project.relative_to(ROOT_DIR) for project in configured_projects - discovered_projects)
if missing or extra:
messages: list[str] = []
if missing:
messages.append("Unconfigured runtime modules:\n - " + "\n - ".join(str(path) for path in missing))
if extra:
messages.append("Configured modules that are not eligible runtime packages:\n - " +
"\n - ".join(str(path) for path in extra))
raise SystemExit("\n".join(messages))
def check_or_write(path: Path, expected: str, encoding: str, check_only: bool, changed: list[Path]) -> None:
if path.exists():
current, _ = load_text(path)
if current == expected:
return
elif check_only:
changed.append(path)
return
if check_only:
changed.append(path)
return
write_text(path, expected, encoding)
changed.append(path)
def main() -> None:
args = parse_args()
modules = load_modules()
validate_module_coverage(modules)
changed: list[Path] = []
for module in modules:
package_id = resolve_package_id(module.project_path)
props_path = module.project_path.parent / "buildTransitive" / f"{package_id}.props"
props_content = render_props(module)
check_or_write(props_path, props_content, "utf-8", args.check, changed)
updated_project, encoding = update_csproj(module.project_path, package_id)
check_or_write(module.project_path, updated_project, encoding, args.check, changed)
if args.check:
if changed:
relative_paths = "\n".join(f" - {path.relative_to(ROOT_DIR)}" for path in changed)
raise SystemExit(f"Generated module global usings are out of date:\n{relative_paths}")
print("Module global usings are up to date.")
return
if changed:
for path in changed:
print(f"Updated {path.relative_to(ROOT_DIR)}")
return
print("No module global usings changes were needed.")
if __name__ == "__main__":
main()