Source code for bacommon.langstr._blob

# Released under the MIT License. See LICENSE for details.
#
"""Canonical serialization for per-locale language-string blobs.

The single on-disk/wire form of a resolved asset-package's per-locale
strings: a ``{'strings': {name: value}}`` JSON object where each value is a
plain ``str`` or a :class:`~bacommon.loctext.StringSelector` in its compact
dict form. One format, produced by the asset-build recipe and the vendor
command alike and parsed by the client decode -- :func:`parse_language_blob`
is the exact inverse of :func:`serialize_language_blob`, so producers and
consumer can never drift.

New-format strings live under the top-level ``strings`` key; the old
per-locale language data uses a sibling ``legacy`` key (a different on-disk
shape). A package ships one or the other.
"""

from __future__ import annotations  # Docs-generation hack.

import json

from typing import TYPE_CHECKING

from efro.dataclassio import dataclass_to_dict, dataclass_from_dict
from bacommon.loctext import StringSelector

if TYPE_CHECKING:
    from bacommon.langstr._core import WrapParams

#: Top-level key the new-format strings live under (sibling to ``legacy``).
LANGUAGE_BLOB_STRINGS_KEY = 'strings'

#: Top-level key holding formatter components copied in at build time
#: (unit words for ``data_size`` and friends).
#:
#: A sibling of ``strings`` rather than a reserved path inside it, and
#: that is load-bearing. String indices are assigned in sorted-name
#: order over the ``strings`` map; the producer derives them from briefs
#: (no components) and the consumer from the blob. Component entries
#: living in ``strings`` would shift every authored string's index on
#: the consumer side only, and :class:`LangStrSpecResourceIndexed` would
#: decode to the wrong string. Out here they cannot perturb anything,
#: and no name needs reserving in the authored namespace.
LANGUAGE_BLOB_COMPONENTS_KEY = 'components'


[docs] def serialize_language_blob( values: dict[str, str | StringSelector], wraps: 'dict[str, WrapParams] | None' = None, param_kinds: dict[str, dict[str, str]] | None = None, components: 'dict[str, str | StringSelector] | None' = None, ) -> str: """Serialize a per-locale value map to the canonical language blob. ``values`` maps each string's logical name to its value -- a plain ``str`` or a :class:`StringSelector`. ``wraps`` optionally maps names to their definition-time :class:`WrapParams` (decision D-t); a wrapped entry is emitted as a ``{'v': value, 'w': wrap}`` carrier dict (which pre-wrap clients skip fail-soft). Output is deterministic (sorted keys, fixed formatting) for cache stability and diffability. ``param_kinds`` optionally maps a name to its ``{param: kind}`` for params whose kind the *display* side must know -- a byte count rendered as "1.2 GB" cannot be recovered from the translated text, which carries only a ``{name}`` token. It rides the same carrier under ``'k'``. Pass only the params that actually need it (anything but ``'text'``). A string with none is emitted exactly as before, so adding this leaves every existing blob byte-identical -- which matters because blob content is a cache key. """ def _encode(name: str, value: str | StringSelector) -> object: out: object = ( dataclass_to_dict(value) if isinstance(value, StringSelector) else value ) wrap = None if wraps is None else wraps.get(name) kinds = None if param_kinds is None else param_kinds.get(name) if wrap is not None or kinds: carrier: dict[str, object] = {'v': out} if wrap is not None: carrier['w'] = dataclass_to_dict(wrap) if kinds: carrier['k'] = dict(sorted(kinds.items())) out = carrier return out out: dict[str, object] = { LANGUAGE_BLOB_STRINGS_KEY: { name: _encode(name, value) for name, value in values.items() } } if components: out[LANGUAGE_BLOB_COMPONENTS_KEY] = { name: ( dataclass_to_dict(value) if isinstance(value, StringSelector) else value ) for name, value in components.items() } return json.dumps( out, ensure_ascii=False, indent=1, sort_keys=True, )
[docs] def parse_language_blob(text: str) -> dict[str, str | StringSelector]: """Parse a canonical language blob into a ``{name: value}`` map. The exact inverse of :func:`serialize_language_blob`: reads the top-level ``strings`` object, turning each value back into a ``str`` (plain) or a :class:`StringSelector` (a dict). A blob with no ``strings`` key (e.g. a legacy-only package) yields an empty map; malformed values are skipped (fail-soft on the consumer side). """ blob = json.loads(text) strings = ( blob.get(LANGUAGE_BLOB_STRINGS_KEY) if isinstance(blob, dict) else None ) if not isinstance(strings, dict): return {} out: dict[str, str | StringSelector] = {} for name, value in strings.items(): # A {'v': ..., 'w': ...} carrier (decision D-t) holds the value # plus its definition-time wrap hint; only the value matters # here (the native tables read the wrap themselves). if isinstance(value, dict) and 'v' in value: value = value['v'] if isinstance(value, str): out[name] = value elif isinstance(value, dict): out[name] = dataclass_from_dict(StringSelector, value) return out
[docs] def parse_language_components(text: str) -> dict[str, str | StringSelector]: """Read the build-embedded formatter components out of a blob. Same value shapes as :func:`parse_language_blob` (plain ``str`` or :class:`StringSelector`), read from the sibling ``components`` key. Absent on any package with no spec'd params, and on every blob written before components existed -- both yield an empty map rather than an error. """ blob = json.loads(text) comps = ( blob.get(LANGUAGE_BLOB_COMPONENTS_KEY) if isinstance(blob, dict) else None ) if not isinstance(comps, dict): return {} out: dict[str, str | StringSelector] = {} for name, value in comps.items(): if isinstance(value, str): out[name] = value elif isinstance(value, dict): out[name] = dataclass_from_dict(StringSelector, value) return out
[docs] def parse_language_param_kinds(text: str) -> dict[str, dict[str, str]]: """Read the ``{name: {param: kind}}`` map out of a language blob. The display-side counterpart of ``param_kinds`` in :func:`serialize_language_blob`. Only strings carrying a non-text param appear; everything else is absent and reads as plain text substitution, which is what a blob written before this existed yields for every entry. Fail-soft throughout, matching :func:`parse_language_blob` -- a malformed entry is skipped rather than failing the whole blob. """ blob = json.loads(text) strings = ( blob.get(LANGUAGE_BLOB_STRINGS_KEY) if isinstance(blob, dict) else None ) if not isinstance(strings, dict): return {} out: dict[str, dict[str, str]] = {} for name, value in strings.items(): if not isinstance(value, dict) or 'v' not in value: continue kinds = value.get('k') if not isinstance(kinds, dict): continue clean = { param: kind for param, kind in kinds.items() if isinstance(param, str) and isinstance(kind, str) } if clean: out[name] = clean return 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