/
/
1"""
2Generate the English source file that Lokalise syncs against.
3
4Concatenates every ``strings.json`` authoring file (music_assistant/strings.json + each
5per-provider/per-controller strings.json) into one flat, fully-qualified ``key -> English``
6JSON at ``music_assistant/translations/en.json``. ``lokalise-upload.yml`` pushes that file to
7Lokalise; ``lokalise-download.yml`` pulls the translated languages back into translations/.
8
9A string value may reference another (typically shared ``common.``) string with the Home
10Assistant style token ``[%key:owner::path::to::key%]`` (``::`` separates the dotted key
11segments). Such a reference declares that an owner reuses a shared string: the target is
12validated to exist and the referencing key is omitted from the generated source, so Lokalise
13translates the shared string once while the server resolves the owner's key at runtime via its
14owner -> common fallback.
15
16A duplicated key inside an authoring file would silently lose strings (JSON parsers keep only
17the last value), so the build fails loudly on duplicate keys at any nesting depth.
18
19Standalone (no ``music_assistant`` imports) so it runs under any music-assistant-models version
20and without the full server import chain.
21
22Usage:
23 uv run -m scripts.build_translations # (re)generate the source file
24 uv run -m scripts.build_translations --check # verify it is up to date (CI/pre-commit)
25"""
26
27from __future__ import annotations
28
29import json
30import os
31import re
32import sys
33from typing import Any
34
35import orjson
36
37# ruff: noqa: T201
38
39# repo paths (this file lives at <repo>/scripts/build_translations.py)
40_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
41PACKAGE_ROOT = os.path.join(_REPO_ROOT, "music_assistant")
42PROVIDERS_PATH = os.path.join(PACKAGE_ROOT, "providers")
43CONTROLLERS_PATH = os.path.join(PACKAGE_ROOT, "controllers")
44TRANSLATIONS_PATH = os.path.join(PACKAGE_ROOT, "translations")
45# the shared/common source strings file (subkeys: settings, media, ...) at the package root
46ROOT_STRINGS_FILE = os.path.join(PACKAGE_ROOT, "strings.json")
47
48SOURCE_LANGUAGE = "en"
49SOURCE_FILE = os.path.join(TRANSLATIONS_PATH, f"{SOURCE_LANGUAGE}.json")
50COMMON_PREFIX = "common."
51# Home Assistant style reference token: [%key:owner::path::to::key%] -> owner.path.to.key
52REFERENCE_PATTERN = re.compile(r"^\[%key:(.+)%\]$")
53
54
55def build_translations_source() -> dict[str, str]:
56 """Assemble the flat English source from all authoring strings.json files."""
57 raw: dict[str, str] = {}
58 duplicates: list[str] = []
59 for prefix, path in _collect_source_files():
60 with open(path, "rb") as file:
61 content = file.read()
62 # orjson below silently keeps the last value when an object repeats a key, which
63 # would drop strings from the generated source; detect duplicates loudly instead.
64 rel_path = os.path.relpath(path, _REPO_ROOT)
65 try:
66 duplicates.extend(
67 f"{rel_path}: {key_path}" for key_path in _find_duplicate_keys(content)
68 )
69 data = orjson.loads(content)
70 except (json.JSONDecodeError, UnicodeDecodeError) as err:
71 raise ValueError(f"{rel_path}: {err}") from err
72 _flatten_into(data, prefix, raw)
73 if duplicates:
74 raise ValueError("Duplicate strings.json key(s):\n " + "\n ".join(sorted(duplicates)))
75 return _resolve_references(raw)
76
77
78def _collect_source_files() -> list[tuple[str, str]]:
79 """Discover all English source strings.json files as (key prefix, path) pairs."""
80 source_files: list[tuple[str, str]] = []
81 # shared/common strings at the package root
82 if os.path.isfile(ROOT_STRINGS_FILE):
83 source_files.append((COMMON_PREFIX, ROOT_STRINGS_FILE))
84 # per-provider strings (sibling of manifest.json); skip template/test providers (their
85 # strings must not reach Lokalise as translator noise)
86 for entry in _iter_subdirs(PROVIDERS_PATH):
87 if entry.startswith("_") or entry == "test":
88 continue
89 path = os.path.join(PROVIDERS_PATH, entry, "strings.json")
90 if os.path.isfile(path):
91 source_files.append((f"provider.{entry}.", path))
92 # per-package-controller strings
93 for entry in _iter_subdirs(CONTROLLERS_PATH):
94 path = os.path.join(CONTROLLERS_PATH, entry, "strings.json")
95 if os.path.isfile(path):
96 source_files.append((f"core.{entry}.", path))
97 return source_files
98
99
100def _iter_subdirs(path: str) -> list[str]:
101 """Return non-hidden subdirectory names of a path (empty if it does not exist)."""
102 if not os.path.isdir(path):
103 return []
104 return [
105 entry
106 for entry in os.listdir(path) # noqa: PTH208
107 if not entry.startswith(".") and os.path.isdir(os.path.join(path, entry))
108 ]
109
110
111class _RawJsonObject(list[tuple[str, Any]]):
112 """A JSON object kept as its raw key/value pairs, so duplicate keys stay observable."""
113
114
115def _find_duplicate_keys(content: bytes) -> list[str]:
116 """
117 Return the dotted key path of every duplicated object key in a JSON document.
118
119 :param content: The raw JSON document to inspect.
120 """
121 duplicates: list[str] = []
122
123 def _walk(node: Any, path: str) -> None:
124 if isinstance(node, _RawJsonObject):
125 seen: set[str] = set()
126 for key, value in node:
127 key_path = f"{path}.{key}" if path else key
128 if key in seen:
129 duplicates.append(key_path)
130 seen.add(key)
131 _walk(value, key_path)
132 elif isinstance(node, list):
133 for index, value in enumerate(node):
134 _walk(value, f"{path}[{index}]")
135
136 _walk(json.loads(content, object_pairs_hook=_RawJsonObject), "")
137 return duplicates
138
139
140def _flatten_into(data: dict[str, Any], prefix: str, out: dict[str, str]) -> None:
141 """Flatten a nested strings dict into dotted, prefixed keys with string leaves."""
142 for key, value in data.items():
143 full_key = f"{prefix}{key}"
144 if isinstance(value, dict):
145 _flatten_into(value, f"{full_key}.", out)
146 elif isinstance(value, str):
147 out[full_key] = value
148
149
150def _resolve_references(raw: dict[str, str]) -> dict[str, str]:
151 """
152 Drop reference-valued keys after validating each points at an existing concrete string.
153
154 A reference value ``[%key:owner::path::to::key%]`` declares that this key reuses a shared
155 string defined elsewhere; the referenced key stays the single translatable source, so the
156 referencing key is left out of the generated catalog (the server resolves it at runtime via
157 its owner -> common fallback).
158
159 :param raw: The flattened catalog, still containing any reference-valued keys.
160 :raises ValueError: When a reference points at a key that is not a concrete string.
161 """
162 concrete = {key: value for key, value in raw.items() if not REFERENCE_PATTERN.match(value)}
163 unresolved: list[str] = []
164 for key, value in raw.items():
165 if not (match := REFERENCE_PATTERN.match(value)):
166 continue
167 target = match.group(1).replace("::", ".")
168 if target not in concrete:
169 unresolved.append(f"{key} -> {target}")
170 if unresolved:
171 raise ValueError(
172 "Unresolved translation reference target(s): " + ", ".join(sorted(unresolved))
173 )
174 return concrete
175
176
177def _render(catalog: dict[str, str]) -> bytes:
178 """Render the catalog as deterministic, sorted, indented JSON."""
179 return orjson.dumps(
180 dict(sorted(catalog.items())),
181 option=orjson.OPT_INDENT_2 | orjson.OPT_APPEND_NEWLINE,
182 )
183
184
185def main() -> int:
186 """Generate (or, with --check, validate) the Lokalise source file."""
187 try:
188 source = build_translations_source()
189 except ValueError as err:
190 print(str(err), file=sys.stderr)
191 return 1
192 rendered = _render(source)
193 if "--check" in sys.argv[1:]:
194 existing = b""
195 if os.path.isfile(SOURCE_FILE):
196 with open(SOURCE_FILE, "rb") as file:
197 existing = file.read()
198 if existing != rendered:
199 print(
200 f"{SOURCE_FILE} is out of date. "
201 "Run `uv run -m scripts.build_translations` and commit the result.",
202 file=sys.stderr,
203 )
204 return 1
205 return 0
206 os.makedirs(TRANSLATIONS_PATH, exist_ok=True)
207 with open(SOURCE_FILE, "wb") as file:
208 file.write(rendered)
209 print(f"Wrote {len(source)} source strings to {SOURCE_FILE}")
210 return 0
211
212
213if __name__ == "__main__":
214 raise SystemExit(main())
215