Source code for bacommon.langstr._codegen

# Released under the MIT License. See LICENSE for details.
#
"""Codegen for type-safe language-string wrapper modules.

Given a :class:`~bacommon.langstr.PackageDef`, emit a Python module whose
``strings`` object gives type-safe, ergonomic access to the package's
strings: a no-arg string reads as a property yielding an
:class:`~bacommon.langstr.Lstr`; a parameterized one is a call that builds
an ``Lstr`` from keyword substitutions. Precise types live in an
``if TYPE_CHECKING`` shadow: a no-arg string is a ``name: Lstr`` annotation,
a parameterized one its own stub method (``def name(self, *, …) -> Lstr``)
-- readable inline and a home for a future per-string docstring. Runtime
resolution is the dynamic :class:`~bacommon.langstr.LangStrDir` (no
per-entry runtime class -- decision #28).

Logical paths split on ``/`` into subdir nesting (``ui/mainmenu/play`` ->
``strings.ui.mainmenu.play``); each path segment must be an identifier.
"""

from __future__ import annotations  # Docs-generation hack.

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from bacommon.langstr._core import PackageDef, StringDef


def _param_type(kind: str) -> str:
    """Annotation for one substitution keyword by its kind."""
    # 'count' is the plural pivot (an int); anything else is a text sub,
    # which accepts a literal string or a nested language-string.
    return 'int' if kind == 'count' else 'str | Lstr'


#: A nested name tree: a leaf maps a name to its :class:`StringDef`, a
#: subdir maps a name to a nested tree (paths split on ``/``).
type _Tree = dict[str, 'StringDef | _Tree']

#: The runtime form of the tree (no kinds): leaf -> param-keyword tuple.
type _RuntimeTree = dict[str, 'tuple[str, ...] | _RuntimeTree']


def _build_tree(strings: tuple[StringDef, ...]) -> _Tree:
    """Split each ``foo/bar`` logical path into a nested name tree."""
    root: _Tree = {}
    for sdef in strings:
        parts = sdef.path.split('/')
        node = root
        for part in parts[:-1]:
            child = node.get(part)
            if child is None:
                child = {}
                node[part] = child
            if not isinstance(child, dict):
                raise ValueError(
                    f'path {sdef.path!r} collides with a string at {part!r}'
                )
            node = child
        leaf = parts[-1]
        if leaf in node:
            raise ValueError(f'duplicate string path {sdef.path!r}')
        node[leaf] = sdef
    return root


def _pascal_case(segment: str) -> str:
    """``some_thing`` -> ``SomeThing``."""
    return ''.join(part.capitalize() for part in segment.split('_'))


def _dir_class_name(segments: list[str]) -> str:
    """Public class name for a group subtree (mirrors the client wrappers).

    The root is ``StringsLibrary``; a subdir is its path PascalCased +
    ``Group`` (``['ui', 'menu']`` -> ``UiMenuGroup``).
    """
    if not segments:
        return 'StringsLibrary'
    return ''.join(_pascal_case(s) for s in segments) + 'Group'


def _emit_dir_classes(out: list[str], tree: _Tree, segments: list[str]) -> None:
    """Emit the typed class for this dir (children first, so refs resolve).

    A subdir is a typed attribute pointing at its group class; a no-arg
    string is a property-style ``name: Lstr``; a parameterized string is its
    own stub method (readable inline, and a home for a future docstring).
    """
    for name in sorted(tree):
        value = tree[name]
        if isinstance(value, dict):
            _emit_dir_classes(out, value, [*segments, name])
    label = '/'.join(segments) if segments else 'library root'
    out += [
        f'    class {_dir_class_name(segments)}:',
        f'        """Type-safe language-string accessors ({label})."""',
        '',
    ]
    for name in sorted(tree):
        value = tree[name]
        if isinstance(value, dict):
            sub = _dir_class_name([*segments, name])
            out.append(f'        {name}: {sub}')
        elif value.params:
            args = ', '.join(
                f'{k}: {_param_type(kind)}' for k, kind in value.params
            )
            out.append(f'        def {name}(self, *, {args}) -> Lstr: ...')
        else:
            out.append(f'        {name}: Lstr')
    out.append('')


def _runtime_tree(tree: _Tree) -> _RuntimeTree:
    out: _RuntimeTree = {}
    for name, value in tree.items():
        out[name] = (
            _runtime_tree(value)
            if isinstance(value, dict)
            else tuple(k for k, _kind in value.params)
        )
    return out


[docs] def generate_wrapper_module(pkgdef: PackageDef) -> str: """Return the ``.py`` source for a package's type-safe wrapper. The output is valid but not auto-formatted (the long ``_TREE`` literal in particular); a caller writing it to the tree should run the formatter before committing. """ for sdef in pkgdef.strings: for part in sdef.path.split('/'): if not part.isidentifier(): raise ValueError(f'bad path segment {part!r} in {sdef.path!r}') tree = _build_tree(pkgdef.strings) out: list[str] = [ '# Released under the MIT License. See LICENSE for details.', '#', '# AUTO-GENERATED by bacommon.langstr codegen; do not edit by hand.', f'"""Type-safe language-string accessors for {pkgdef.apverid}."""', '', 'from typing import TYPE_CHECKING', '', 'from bacommon.langstr import LangStrDir', '', f"__asset_package__ = '{pkgdef.apverid}'", '', 'if TYPE_CHECKING:', ' from bacommon.langstr import Lstr, WrapperTree', '', ] _emit_dir_classes(out, tree, []) out += [ f' strings: {_dir_class_name([])}', '', # `_TREE` lives at module level (not in the `if not TYPE_CHECKING` # block) so consumers can read it statically -- mirroring the # client asset-package wrappers (bauiv1/builtinassets.py). Annotated # WrapperTree so consumers get a typed tree (and the literal is # checked against the shape). f'_TREE: WrapperTree = {_runtime_tree(tree)!r}', '', 'if not TYPE_CHECKING:', ' strings = LangStrDir(__asset_package__, _TREE)', '', ] return '\n'.join(out)
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum