update-message-registry.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import argparse
  4. import re
  5. import secrets
  6. from dataclasses import astuple, dataclass
  7. from enum import StrEnum, auto, unique
  8. from pathlib import Path
  9. from typing import Any, Callable, ClassVar, Generic, Self, Sequence, TypeAlias, TypeVar
  10. import jinja2
  11. import yaml
  12. @unique
  13. class Type(StrEnum):
  14. Warning = auto()
  15. Error = auto()
  16. @classmethod
  17. def parse(cls, raw: str) -> Self:
  18. for t in cls:
  19. if raw == t:
  20. return t
  21. raise ValueError(raw)
  22. @unique
  23. class Visibility(StrEnum):
  24. Internal = auto()
  25. Public = auto()
  26. @classmethod
  27. def parse(cls, raw: str) -> Self:
  28. for v in cls:
  29. if raw == v:
  30. return v
  31. raise ValueError(raw)
  32. @dataclass(order=True)
  33. class Version:
  34. """A semantic version number: MAJOR.MINOR.PATCH."""
  35. UNKNOWN_VERSION: ClassVar[str] = "ALWAYS"
  36. DEFAULT_VERSION: ClassVar[str] = "1.0.0"
  37. MIN_PUBLIC_VERSION: ClassVar[Self]
  38. major: int
  39. minor: int
  40. patch: int = 0
  41. def __str__(self) -> str:
  42. return ".".join(map(str, astuple(self)))
  43. @classmethod
  44. def parse(cls, raw_version: str) -> Version:
  45. if raw_version == cls.UNKNOWN_VERSION:
  46. raw_version = cls.DEFAULT_VERSION
  47. version = raw_version.split(".")
  48. assert 2 <= len(version) <= 3 and all(n.isdecimal() for n in version), (
  49. raw_version
  50. )
  51. return Version(*map(int, version))
  52. Version.MIN_PUBLIC_VERSION = Version(1, 14, 0)
  53. """The minimum version that exposed public error codes via `xkb_error_code` enum."""
  54. @dataclass
  55. class Example:
  56. """An example in a message entry."""
  57. name: str
  58. description: str
  59. before: str | None
  60. after: str | None
  61. @classmethod
  62. def parse(cls, entry: dict[str, Any]) -> Example:
  63. name = entry.get("name")
  64. assert name, entry
  65. description = entry.get("description")
  66. assert description
  67. before = entry.get("before")
  68. after = entry.get("after")
  69. # Either none or both of them
  70. assert not (bool(before) ^ bool(after))
  71. return Example(name=name, description=description, before=before, after=after)
  72. @dataclass
  73. class Entry:
  74. """An xkbcommon message entry in the message registry"""
  75. code: int
  76. """A unique strictly positive integer identifier"""
  77. id: str
  78. """A unique short human-readable string identifier"""
  79. type: Type
  80. """Visibility in the API"""
  81. visibility: Visibility
  82. """Log level of the message"""
  83. description: str
  84. """A short description of the meaning of the message"""
  85. details: str
  86. """A long description of the meaning of the message"""
  87. added: Version
  88. """Version of xkbcommon the message has been added in xkb_message_code"""
  89. added_public: Version | None
  90. """Version of xkbcommon the message has been added in xkb_error_code"""
  91. removed: Version | None
  92. """Version of xkbcommon the message has been removed"""
  93. examples: tuple[Example, ...]
  94. """
  95. Optional examples of situations in which the message occurs.
  96. If the message is an error or a warning, also provide hints on how to fix it.
  97. """
  98. @classmethod
  99. def parse(cls, entry: dict[str, Any]) -> Entry:
  100. code = entry.get("code")
  101. assert code is not None and isinstance(code, int) and code > 0, entry
  102. id = entry.get("id")
  103. assert id is not None, entry
  104. raw_type = entry.get("type")
  105. type_ = Type.parse(raw_type)
  106. raw_visibility = entry.get("visibility", Visibility.Internal)
  107. visibility = Visibility.parse(raw_visibility)
  108. description = entry.get("description")
  109. assert description is not None, entry
  110. details = entry.get("details", "")
  111. raw_added = entry.get("added", "")
  112. assert raw_added, entry
  113. added_public = None
  114. if isinstance(raw_added, str):
  115. added = Version.parse(raw_added)
  116. else:
  117. # Internal and public variants where released in different versions
  118. for _raw_visibility, _raw_added in raw_added.items():
  119. _visibility = Visibility.parse(_raw_visibility)
  120. _added = Version.parse(_raw_added)
  121. match _visibility:
  122. case Visibility.Internal:
  123. added = _added
  124. case Visibility.Public:
  125. added_public = _added
  126. assert added, entry
  127. if added_public is None and visibility is Visibility.Public:
  128. # Internal and public variants where release in the same version
  129. added_public = added
  130. if removed := entry.get("removed"):
  131. removed = Version.parse(removed)
  132. assert added < removed, entry
  133. if examples := entry.get("examples", ()):
  134. examples = tuple(map(Example.parse, examples))
  135. return Entry(
  136. code=code,
  137. id=id,
  138. type=type_,
  139. visibility=visibility,
  140. description=description,
  141. added=added,
  142. added_public=added_public,
  143. removed=removed,
  144. details=details,
  145. examples=examples,
  146. )
  147. @property
  148. def message_code(self) -> str:
  149. """Format the message code for display"""
  150. return f"XKB-{self.code:0>3}"
  151. def message_code_constant(self, visibility: Visibility) -> str:
  152. """Returns the C enumeration member denoting the message code"""
  153. id = self.id.replace("-", "_").upper()
  154. suffix = (
  155. "_"
  156. if self.visibility is Visibility.Public
  157. and visibility is not self.visibility
  158. else ""
  159. )
  160. return f"XKB_{self.type.upper()}_{id}{suffix}"
  161. @property
  162. def message_name(self) -> str:
  163. """Format the message string identifier for display"""
  164. return self.id.replace("-", " ").capitalize()
  165. Registry: TypeAlias = Sequence[Entry]
  166. def prepend_todo(text: str) -> str:
  167. if text.startswith("TODO"):
  168. return f"""<span class="todo">{text[:5]}</span>{text[5:]}"""
  169. else:
  170. return text
  171. def load_message_registry(
  172. env: jinja2.Environment, constants: dict[str, int], path: Path
  173. ) -> Registry:
  174. # Load the message registry YAML file as a Jinja2 template
  175. registry_template = env.get_template(str(path))
  176. # Load message registry
  177. message_registry = sorted(
  178. map(Entry.parse, yaml.safe_load(registry_template.render(constants))),
  179. key=lambda e: e.code,
  180. )
  181. # Check message codes and identifiers are unique
  182. codes: set[int] = set()
  183. identifiers: set[str] = set()
  184. for n, entry in enumerate(message_registry):
  185. if entry.code in codes:
  186. raise ValueError(f"Duplicated code in entry #{n}: {entry.code}")
  187. if entry.id in identifiers:
  188. raise ValueError(f"Duplicated identifier in entry #{n}: {entry.id}")
  189. codes.add(entry.code)
  190. identifiers.add(entry.id)
  191. return message_registry
  192. def generate_file(
  193. registry: Registry,
  194. env: jinja2.Environment,
  195. root: Path,
  196. file: Path,
  197. visibility: Visibility | None = None,
  198. skip_removed: bool = False,
  199. ) -> None:
  200. """Generate a file from its Jinja2 template and the message registry"""
  201. template_path = file.with_suffix(f"{file.suffix}.jinja")
  202. template = env.get_template(str(template_path))
  203. path = root / file
  204. script = Path(__file__).name
  205. entries_iter = registry
  206. if skip_removed:
  207. entries_iter = filter(lambda e: e.removed is None, entries_iter)
  208. if visibility is Visibility.Public:
  209. entries_iter = filter(lambda e: e.visibility is visibility, entries_iter)
  210. entries = tuple(entries_iter)
  211. with path.open("wt", encoding="utf-8") as fd:
  212. fd.writelines(
  213. template.generate(visibility=visibility, entries=entries, script=script)
  214. )
  215. T = TypeVar("T")
  216. @dataclass
  217. class Constant(Generic[T]):
  218. name: str
  219. pattern: re.Pattern[str]
  220. conversion: Callable[[str], T]
  221. def read_constants(path: Path, patterns: Sequence[Constant[T]]) -> dict[str, T]:
  222. constants: dict[str, T] = {}
  223. patternsʹ = list(patterns)
  224. with path.open("rt", encoding="utf-8") as fd:
  225. for line in fd:
  226. for k, constant in enumerate(patternsʹ):
  227. if m := constant.pattern.match(line):
  228. constants[constant.name] = constant.conversion(m.group(1))
  229. del patternsʹ[k]
  230. continue # Expect only one match per line
  231. if not patternsʹ:
  232. # No more pattern to match
  233. break
  234. for constant in patternsʹ:
  235. print(f"ERROR: could not find constant: {constant.name}.")
  236. if patternsʹ:
  237. raise ValueError("Some constants were not found.")
  238. return constants
  239. def generate(
  240. args: argparse.Namespace, registry: Registry, jinja_env: jinja2.Environment
  241. ):
  242. """
  243. Generate the files
  244. """
  245. generate_file(
  246. registry,
  247. jinja_env,
  248. args.root,
  249. Path("include/xkbcommon/xkbcommon-errors.h"),
  250. visibility=Visibility.Public,
  251. skip_removed=True,
  252. )
  253. generate_file(
  254. registry,
  255. jinja_env,
  256. args.root,
  257. Path("src/messages-codes.h"),
  258. visibility=Visibility.Internal,
  259. skip_removed=True,
  260. )
  261. generate_file(
  262. registry,
  263. jinja_env,
  264. args.root,
  265. Path("tools/messages.c"),
  266. visibility=Visibility.Internal,
  267. skip_removed=True,
  268. )
  269. generate_file(
  270. registry,
  271. jinja_env,
  272. args.root,
  273. Path("doc/message-registry.md"),
  274. visibility=Visibility.Internal,
  275. skip_removed=False,
  276. )
  277. def get_new_code(
  278. args: argparse.Namespace, registry: Registry, jinja_env: jinja2.Environment
  279. ):
  280. """
  281. Get a free code
  282. """
  283. # Get all codes
  284. codes = frozenset(entry.code for entry in registry)
  285. # Filter free ones
  286. free = tuple(code for code in range(args.min, args.max + 1) if code not in codes)
  287. print(*sorted(secrets.choice(free) for _ in range(args.count)))
  288. # Root of the project
  289. ROOT = Path(__file__).parent.parent
  290. # Parse commands
  291. parser = argparse.ArgumentParser(description="Generate files from the message registry")
  292. parser.add_argument(
  293. "--root",
  294. type=Path,
  295. default=ROOT,
  296. help="Path to the root of the project (default: %(default)s)",
  297. )
  298. parser.set_defaults(func=generate)
  299. subparsers = parser.add_subparsers()
  300. new_id_parser = subparsers.add_parser("get-new-code", help="Get a new message codes")
  301. new_id_parser.set_defaults(func=get_new_code)
  302. new_id_parser.add_argument("--min", type=int, default=1, help="default: %(default)s")
  303. new_id_parser.add_argument("--max", type=int, default=999, help="default: %(default)s")
  304. new_id_parser.add_argument("--count", type=int, default=10, help="default: %(default)s")
  305. generate_parser = subparsers.add_parser("generate", help="Generate files")
  306. args = parser.parse_args()
  307. # Read some constants from libxkbcommon that we need
  308. constants = read_constants(
  309. Path(__file__).parent.parent / "src" / "keymap.h",
  310. (
  311. Constant(
  312. "XKB_MAX_GROUPS",
  313. re.compile(r"^#define\s+XKB_MAX_GROUPS\s+(\d+)"),
  314. int,
  315. ),
  316. Constant(
  317. "XKB_MAX_GROUPS_X11",
  318. re.compile(r"^#define\s+XKB_MAX_GROUPS_X11\s+(\d+)"),
  319. int,
  320. ),
  321. ),
  322. )
  323. # Configure Jinja
  324. template_loader = jinja2.FileSystemLoader(args.root, encoding="utf-8")
  325. jinja_env = jinja2.Environment(
  326. loader=template_loader,
  327. keep_trailing_newline=True,
  328. trim_blocks=True,
  329. lstrip_blocks=True,
  330. )
  331. jinja_env.filters["prepend_todo"] = prepend_todo
  332. # Load message registry
  333. message_registry = load_message_registry(
  334. jinja_env, constants, Path("doc/message-registry.yaml")
  335. )
  336. args.func(args=args, registry=message_registry, jinja_env=jinja_env)