| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406 |
- #!/usr/bin/env python3
- from __future__ import annotations
- import argparse
- import re
- import secrets
- from dataclasses import astuple, dataclass
- from enum import StrEnum, auto, unique
- from pathlib import Path
- from typing import Any, Callable, ClassVar, Generic, Self, Sequence, TypeAlias, TypeVar
- import jinja2
- import yaml
- @unique
- class Type(StrEnum):
- Warning = auto()
- Error = auto()
- @classmethod
- def parse(cls, raw: str) -> Self:
- for t in cls:
- if raw == t:
- return t
- raise ValueError(raw)
- @unique
- class Visibility(StrEnum):
- Internal = auto()
- Public = auto()
- @classmethod
- def parse(cls, raw: str) -> Self:
- for v in cls:
- if raw == v:
- return v
- raise ValueError(raw)
- @dataclass(order=True)
- class Version:
- """A semantic version number: MAJOR.MINOR.PATCH."""
- UNKNOWN_VERSION: ClassVar[str] = "ALWAYS"
- DEFAULT_VERSION: ClassVar[str] = "1.0.0"
- MIN_PUBLIC_VERSION: ClassVar[Self]
- major: int
- minor: int
- patch: int = 0
- def __str__(self) -> str:
- return ".".join(map(str, astuple(self)))
- @classmethod
- def parse(cls, raw_version: str) -> Version:
- if raw_version == cls.UNKNOWN_VERSION:
- raw_version = cls.DEFAULT_VERSION
- version = raw_version.split(".")
- assert 2 <= len(version) <= 3 and all(n.isdecimal() for n in version), (
- raw_version
- )
- return Version(*map(int, version))
- Version.MIN_PUBLIC_VERSION = Version(1, 14, 0)
- """The minimum version that exposed public error codes via `xkb_error_code` enum."""
- @dataclass
- class Example:
- """An example in a message entry."""
- name: str
- description: str
- before: str | None
- after: str | None
- @classmethod
- def parse(cls, entry: dict[str, Any]) -> Example:
- name = entry.get("name")
- assert name, entry
- description = entry.get("description")
- assert description
- before = entry.get("before")
- after = entry.get("after")
- # Either none or both of them
- assert not (bool(before) ^ bool(after))
- return Example(name=name, description=description, before=before, after=after)
- @dataclass
- class Entry:
- """An xkbcommon message entry in the message registry"""
- code: int
- """A unique strictly positive integer identifier"""
- id: str
- """A unique short human-readable string identifier"""
- type: Type
- """Visibility in the API"""
- visibility: Visibility
- """Log level of the message"""
- description: str
- """A short description of the meaning of the message"""
- details: str
- """A long description of the meaning of the message"""
- added: Version
- """Version of xkbcommon the message has been added in xkb_message_code"""
- added_public: Version | None
- """Version of xkbcommon the message has been added in xkb_error_code"""
- removed: Version | None
- """Version of xkbcommon the message has been removed"""
- examples: tuple[Example, ...]
- """
- Optional examples of situations in which the message occurs.
- If the message is an error or a warning, also provide hints on how to fix it.
- """
- @classmethod
- def parse(cls, entry: dict[str, Any]) -> Entry:
- code = entry.get("code")
- assert code is not None and isinstance(code, int) and code > 0, entry
- id = entry.get("id")
- assert id is not None, entry
- raw_type = entry.get("type")
- type_ = Type.parse(raw_type)
- raw_visibility = entry.get("visibility", Visibility.Internal)
- visibility = Visibility.parse(raw_visibility)
- description = entry.get("description")
- assert description is not None, entry
- details = entry.get("details", "")
- raw_added = entry.get("added", "")
- assert raw_added, entry
- added_public = None
- if isinstance(raw_added, str):
- added = Version.parse(raw_added)
- else:
- # Internal and public variants where released in different versions
- for _raw_visibility, _raw_added in raw_added.items():
- _visibility = Visibility.parse(_raw_visibility)
- _added = Version.parse(_raw_added)
- match _visibility:
- case Visibility.Internal:
- added = _added
- case Visibility.Public:
- added_public = _added
- assert added, entry
- if added_public is None and visibility is Visibility.Public:
- # Internal and public variants where release in the same version
- added_public = added
- if removed := entry.get("removed"):
- removed = Version.parse(removed)
- assert added < removed, entry
- if examples := entry.get("examples", ()):
- examples = tuple(map(Example.parse, examples))
- return Entry(
- code=code,
- id=id,
- type=type_,
- visibility=visibility,
- description=description,
- added=added,
- added_public=added_public,
- removed=removed,
- details=details,
- examples=examples,
- )
- @property
- def message_code(self) -> str:
- """Format the message code for display"""
- return f"XKB-{self.code:0>3}"
- def message_code_constant(self, visibility: Visibility) -> str:
- """Returns the C enumeration member denoting the message code"""
- id = self.id.replace("-", "_").upper()
- suffix = (
- "_"
- if self.visibility is Visibility.Public
- and visibility is not self.visibility
- else ""
- )
- return f"XKB_{self.type.upper()}_{id}{suffix}"
- @property
- def message_name(self) -> str:
- """Format the message string identifier for display"""
- return self.id.replace("-", " ").capitalize()
- Registry: TypeAlias = Sequence[Entry]
- def prepend_todo(text: str) -> str:
- if text.startswith("TODO"):
- return f"""<span class="todo">{text[:5]}</span>{text[5:]}"""
- else:
- return text
- def load_message_registry(
- env: jinja2.Environment, constants: dict[str, int], path: Path
- ) -> Registry:
- # Load the message registry YAML file as a Jinja2 template
- registry_template = env.get_template(str(path))
- # Load message registry
- message_registry = sorted(
- map(Entry.parse, yaml.safe_load(registry_template.render(constants))),
- key=lambda e: e.code,
- )
- # Check message codes and identifiers are unique
- codes: set[int] = set()
- identifiers: set[str] = set()
- for n, entry in enumerate(message_registry):
- if entry.code in codes:
- raise ValueError(f"Duplicated code in entry #{n}: {entry.code}")
- if entry.id in identifiers:
- raise ValueError(f"Duplicated identifier in entry #{n}: {entry.id}")
- codes.add(entry.code)
- identifiers.add(entry.id)
- return message_registry
- def generate_file(
- registry: Registry,
- env: jinja2.Environment,
- root: Path,
- file: Path,
- visibility: Visibility | None = None,
- skip_removed: bool = False,
- ) -> None:
- """Generate a file from its Jinja2 template and the message registry"""
- template_path = file.with_suffix(f"{file.suffix}.jinja")
- template = env.get_template(str(template_path))
- path = root / file
- script = Path(__file__).name
- entries_iter = registry
- if skip_removed:
- entries_iter = filter(lambda e: e.removed is None, entries_iter)
- if visibility is Visibility.Public:
- entries_iter = filter(lambda e: e.visibility is visibility, entries_iter)
- entries = tuple(entries_iter)
- with path.open("wt", encoding="utf-8") as fd:
- fd.writelines(
- template.generate(visibility=visibility, entries=entries, script=script)
- )
- T = TypeVar("T")
- @dataclass
- class Constant(Generic[T]):
- name: str
- pattern: re.Pattern[str]
- conversion: Callable[[str], T]
- def read_constants(path: Path, patterns: Sequence[Constant[T]]) -> dict[str, T]:
- constants: dict[str, T] = {}
- patternsʹ = list(patterns)
- with path.open("rt", encoding="utf-8") as fd:
- for line in fd:
- for k, constant in enumerate(patternsʹ):
- if m := constant.pattern.match(line):
- constants[constant.name] = constant.conversion(m.group(1))
- del patternsʹ[k]
- continue # Expect only one match per line
- if not patternsʹ:
- # No more pattern to match
- break
- for constant in patternsʹ:
- print(f"ERROR: could not find constant: {constant.name}.")
- if patternsʹ:
- raise ValueError("Some constants were not found.")
- return constants
- def generate(
- args: argparse.Namespace, registry: Registry, jinja_env: jinja2.Environment
- ):
- """
- Generate the files
- """
- generate_file(
- registry,
- jinja_env,
- args.root,
- Path("include/xkbcommon/xkbcommon-errors.h"),
- visibility=Visibility.Public,
- skip_removed=True,
- )
- generate_file(
- registry,
- jinja_env,
- args.root,
- Path("src/messages-codes.h"),
- visibility=Visibility.Internal,
- skip_removed=True,
- )
- generate_file(
- registry,
- jinja_env,
- args.root,
- Path("tools/messages.c"),
- visibility=Visibility.Internal,
- skip_removed=True,
- )
- generate_file(
- registry,
- jinja_env,
- args.root,
- Path("doc/message-registry.md"),
- visibility=Visibility.Internal,
- skip_removed=False,
- )
- def get_new_code(
- args: argparse.Namespace, registry: Registry, jinja_env: jinja2.Environment
- ):
- """
- Get a free code
- """
- # Get all codes
- codes = frozenset(entry.code for entry in registry)
- # Filter free ones
- free = tuple(code for code in range(args.min, args.max + 1) if code not in codes)
- print(*sorted(secrets.choice(free) for _ in range(args.count)))
- # Root of the project
- ROOT = Path(__file__).parent.parent
- # Parse commands
- parser = argparse.ArgumentParser(description="Generate files from the message registry")
- parser.add_argument(
- "--root",
- type=Path,
- default=ROOT,
- help="Path to the root of the project (default: %(default)s)",
- )
- parser.set_defaults(func=generate)
- subparsers = parser.add_subparsers()
- new_id_parser = subparsers.add_parser("get-new-code", help="Get a new message codes")
- new_id_parser.set_defaults(func=get_new_code)
- new_id_parser.add_argument("--min", type=int, default=1, help="default: %(default)s")
- new_id_parser.add_argument("--max", type=int, default=999, help="default: %(default)s")
- new_id_parser.add_argument("--count", type=int, default=10, help="default: %(default)s")
- generate_parser = subparsers.add_parser("generate", help="Generate files")
- args = parser.parse_args()
- # Read some constants from libxkbcommon that we need
- constants = read_constants(
- Path(__file__).parent.parent / "src" / "keymap.h",
- (
- Constant(
- "XKB_MAX_GROUPS",
- re.compile(r"^#define\s+XKB_MAX_GROUPS\s+(\d+)"),
- int,
- ),
- Constant(
- "XKB_MAX_GROUPS_X11",
- re.compile(r"^#define\s+XKB_MAX_GROUPS_X11\s+(\d+)"),
- int,
- ),
- ),
- )
- # Configure Jinja
- template_loader = jinja2.FileSystemLoader(args.root, encoding="utf-8")
- jinja_env = jinja2.Environment(
- loader=template_loader,
- keep_trailing_newline=True,
- trim_blocks=True,
- lstrip_blocks=True,
- )
- jinja_env.filters["prepend_todo"] = prepend_todo
- # Load message registry
- message_registry = load_message_registry(
- jinja_env, constants, Path("doc/message-registry.yaml")
- )
- args.func(args=args, registry=message_registry, jinja_env=jinja_env)
|