|
| 1 | +"""Repository tool discovery and PyKotor library path constants (CI + compile scripts).""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import re |
| 6 | +import sys |
| 7 | +from dataclasses import dataclass, field |
| 8 | +from pathlib import Path |
| 9 | + |
| 10 | +if sys.version_info >= (3, 11): |
| 11 | + import tomllib |
| 12 | +else: |
| 13 | + try: |
| 14 | + import tomli as tomllib |
| 15 | + except ImportError: |
| 16 | + tomllib = None # type: ignore[assignment, misc] |
| 17 | + |
| 18 | +LIBRARY_SOURCE_PATHS: list[str] = [ |
| 19 | + "Libraries/PyKotor/src", |
| 20 | + "Libraries/bioware-kaitai-formats", |
| 21 | +] |
| 22 | +LIBRARY_TEST_PATHS: list[str] = [ |
| 23 | + "Libraries/PyKotor/tests", |
| 24 | + "Libraries/bioware-kaitai-formats", |
| 25 | +] |
| 26 | + |
| 27 | + |
| 28 | +@dataclass |
| 29 | +class ToolInfo: |
| 30 | + """Metadata for a package under `Tools/`.""" |
| 31 | + |
| 32 | + directory: str |
| 33 | + name: str |
| 34 | + build_name: str |
| 35 | + display_name: str |
| 36 | + path: str |
| 37 | + src_path: str |
| 38 | + module_name: str |
| 39 | + requires_qt: bool |
| 40 | + is_cli: bool |
| 41 | + tests_path: str | None = field(default=None) |
| 42 | + |
| 43 | + @property |
| 44 | + def relative_path(self) -> str: |
| 45 | + return f"Tools/{self.directory}" |
| 46 | + |
| 47 | + def to_dict(self) -> dict[str, object | str | bool | None]: |
| 48 | + return { |
| 49 | + "directory": self.directory, |
| 50 | + "name": self.name, |
| 51 | + "build_name": self.build_name, |
| 52 | + "display_name": self.display_name, |
| 53 | + "path": self.path, |
| 54 | + "src_path": self.src_path, |
| 55 | + "module_name": self.module_name, |
| 56 | + "requires_qt": self.requires_qt, |
| 57 | + "is_cli": self.is_cli, |
| 58 | + } |
| 59 | + |
| 60 | + |
| 61 | +def _read_pyproject_data(tool_dir: Path) -> dict[str, object]: |
| 62 | + pyproject = tool_dir / "pyproject.toml" |
| 63 | + if not pyproject.is_file() or tomllib is None: |
| 64 | + return {} |
| 65 | + try: |
| 66 | + return tomllib.load(pyproject.read_bytes()) # type: ignore[no-untyped-call] |
| 67 | + except (OSError, TypeError, ValueError, UnicodeError): |
| 68 | + return {} |
| 69 | + |
| 70 | + |
| 71 | +def _script_module(data: dict[str, object]) -> str: |
| 72 | + project = data.get("project") |
| 73 | + if not isinstance(project, dict): |
| 74 | + return "" |
| 75 | + scripts = project.get("scripts") |
| 76 | + if not isinstance(scripts, dict) or not scripts: |
| 77 | + return "" |
| 78 | + for v in scripts.values(): |
| 79 | + if isinstance(v, str) and ":" in v: |
| 80 | + return v.split(":", 1)[0].strip() |
| 81 | + return "" |
| 82 | + |
| 83 | + |
| 84 | +def _infer_requires_qt(text: str) -> bool: |
| 85 | + low = text.lower() |
| 86 | + return "pyqt" in low or "pyside" in low or "qt5" in low or "qt6" in low |
| 87 | + |
| 88 | + |
| 89 | +def _one_tool(repo_root: Path, tool_dir: Path) -> ToolInfo | None: |
| 90 | + if not (tool_dir / "pyproject.toml").is_file(): |
| 91 | + return None |
| 92 | + directory = tool_dir.name |
| 93 | + data = _read_pyproject_data(tool_dir) |
| 94 | + project = data.get("project") if isinstance(data.get("project"), dict) else {} |
| 95 | + proj_name = str(project.get("name", directory)) |
| 96 | + build_name = re.sub(r"[^0-9a-zA-Z]+", "-", proj_name).lower().strip("-") |
| 97 | + if not build_name: |
| 98 | + build_name = directory.lower() |
| 99 | + toml_text = (tool_dir / "pyproject.toml").read_text(encoding="utf-8", errors="replace") |
| 100 | + requires_qt = _infer_requires_qt(toml_text) |
| 101 | + is_cli = not requires_qt |
| 102 | + if (tool_dir / "src").is_dir(): |
| 103 | + src = tool_dir / "src" |
| 104 | + else: |
| 105 | + src = tool_dir |
| 106 | + src_path = str(src.relative_to(repo_root)) |
| 107 | + tests: Path | None = None |
| 108 | + for tname in ("tests", "test"): |
| 109 | + tpath = tool_dir / tname |
| 110 | + if tpath.is_dir(): |
| 111 | + tests = tpath |
| 112 | + break |
| 113 | + tests_path = str(tests.relative_to(repo_root)) if tests is not None else None |
| 114 | + mod = _script_module(data) |
| 115 | + if not mod: |
| 116 | + if (src / "toolset").is_dir(): |
| 117 | + mod = "toolset" |
| 118 | + elif (src / "pykotor").is_dir(): |
| 119 | + mod = "pykotor" |
| 120 | + else: |
| 121 | + mod = directory |
| 122 | + return ToolInfo( |
| 123 | + directory=directory, |
| 124 | + name=build_name, |
| 125 | + build_name=build_name, |
| 126 | + display_name=proj_name, |
| 127 | + path=f"Tools/{directory}", |
| 128 | + src_path=src_path, |
| 129 | + module_name=mod, |
| 130 | + requires_qt=requires_qt, |
| 131 | + is_cli=is_cli, |
| 132 | + tests_path=tests_path, |
| 133 | + ) |
| 134 | + |
| 135 | + |
| 136 | +def discover_tools(repo_root: Path | str) -> list[ToolInfo]: |
| 137 | + """List each `Tools/*/` that contains a `pyproject.toml`.""" |
| 138 | + root = Path(repo_root).resolve() |
| 139 | + tools_base = root / "Tools" |
| 140 | + if not tools_base.is_dir(): |
| 141 | + return [] |
| 142 | + out: list[ToolInfo] = [] |
| 143 | + for child in sorted(tools_base.iterdir(), key=lambda p: p.name.lower()): |
| 144 | + if not child.is_dir() or child.name.startswith("."): |
| 145 | + continue |
| 146 | + t = _one_tool(root, child) |
| 147 | + if t is not None: |
| 148 | + out.append(t) |
| 149 | + return out |
| 150 | + |
| 151 | + |
| 152 | +def resolve_tool(name: str, repo_root: Path | str) -> ToolInfo: |
| 153 | + """Return tool metadata; *name* matches directory, build name, or project name (casefold).""" |
| 154 | + n = (name or "").strip().casefold() |
| 155 | + for tool in discover_tools(repo_root): |
| 156 | + if n in { |
| 157 | + tool.directory.casefold(), |
| 158 | + tool.build_name.casefold(), |
| 159 | + tool.name.casefold(), |
| 160 | + tool.display_name.casefold(), |
| 161 | + }: |
| 162 | + return tool |
| 163 | + msg = f"Unknown tool: {name!r}" |
| 164 | + raise KeyError(msg) |
0 commit comments