148 lines
5.2 KiB
Python
148 lines
5.2 KiB
Python
"""Translation marker management for Custom Fields.
|
|
|
|
Custom Field labels and descriptions defined in `custom_fields.py` are bare
|
|
Python strings inside `dict(label='...', description='...')` literals. Frappe's
|
|
gettext extractor (`bench generate-pot-file`) scans .py files only for
|
|
`__()`/`_()`/`_lt()` calls — it cannot see bare strings inside dict literals.
|
|
Result: those labels never make it into `jey_erp.pot`, then into `.po`, then
|
|
into runtime translations — even though Frappe DOES wrap them in `__()` when
|
|
rendering Custom Field labels at form render time.
|
|
|
|
This module ASTs `custom_fields.py`, harvests every string value of the
|
|
`label` and `description` keyword arguments, and writes them out to
|
|
`translation_markers.py` as `_lt(...)` calls. `_lt` is a lazy translation
|
|
proxy — never rendered, never called for real — but the gettext extractor
|
|
recognizes it the same as `_`/`__`, so the strings show up in the POT.
|
|
|
|
Triggered automatically at the top of `after_migrate_combined()` in `hooks.py`,
|
|
or manually via:
|
|
|
|
bench execute jey_erp.translate.sync_translation_markers
|
|
"""
|
|
|
|
import ast
|
|
import json
|
|
import os
|
|
|
|
|
|
SOURCE_FILE = "custom_fields.py"
|
|
MARKER_FILE = "translation_markers.py"
|
|
EXTRACTED_KWARGS = ("label", "description")
|
|
|
|
_HEADER = '''\
|
|
# AUTO-GENERATED FILE — DO NOT EDIT BY HAND.
|
|
#
|
|
# Regenerated from {source} by jey_erp.translate.sync_translation_markers,
|
|
# invoked at the top of the after_migrate hook in hooks.py.
|
|
#
|
|
# Purpose: expose Custom Field labels/descriptions to bench generate-pot-file.
|
|
# The extractor scans .py files for _lt()/_()/__() calls only; it cannot see
|
|
# bare strings inside dict(label='...') literals. _lt() is a lazy translation
|
|
# proxy with no runtime effect — these calls exist purely so gettext picks
|
|
# the strings up.
|
|
|
|
from frappe import _lt
|
|
|
|
'''
|
|
|
|
|
|
def sync_translation_markers():
|
|
"""Regenerate translation_markers.py from labels in custom_fields.py.
|
|
|
|
Idempotent: skips writing if the regenerated content matches what's already
|
|
on disk. Safe to call repeatedly.
|
|
"""
|
|
app_dir = os.path.dirname(os.path.abspath(__file__))
|
|
source_path = os.path.join(app_dir, SOURCE_FILE)
|
|
marker_path = os.path.join(app_dir, MARKER_FILE)
|
|
|
|
if not os.path.isfile(source_path):
|
|
print(f"[jey_erp] sync_translation_markers: {source_path} not found, skipping")
|
|
return
|
|
|
|
try:
|
|
with open(source_path, encoding="utf-8") as f:
|
|
tree = ast.parse(f.read(), filename=SOURCE_FILE)
|
|
except SyntaxError as e:
|
|
print(f"[jey_erp] sync_translation_markers: failed to parse {SOURCE_FILE}: {e}")
|
|
return
|
|
|
|
labels = _extract_strings(tree)
|
|
new_content = _render(labels)
|
|
|
|
if os.path.exists(marker_path):
|
|
try:
|
|
with open(marker_path, encoding="utf-8") as f:
|
|
if f.read() == new_content:
|
|
print(
|
|
f"[jey_erp] sync_translation_markers: {MARKER_FILE} up to date "
|
|
f"({len(labels)} markers)"
|
|
)
|
|
return
|
|
except OSError as e:
|
|
print(f"[jey_erp] sync_translation_markers: could not read existing {MARKER_FILE}: {e}")
|
|
|
|
try:
|
|
with open(marker_path, "w", encoding="utf-8") as f:
|
|
f.write(new_content)
|
|
except OSError as e:
|
|
print(f"[jey_erp] sync_translation_markers: failed to write {MARKER_FILE}: {e}")
|
|
return
|
|
|
|
print(
|
|
f"[jey_erp] sync_translation_markers: regenerated {MARKER_FILE} "
|
|
f"({len(labels)} markers)"
|
|
)
|
|
|
|
|
|
def _extract_strings(tree):
|
|
"""Walk the AST and harvest string values for `label=` / `description=`.
|
|
|
|
Handles both `dict(label='X', ...)` constructor form and `{'label': 'X', ...}`
|
|
literal form. Skips non-string values (e.g. f-strings, variables).
|
|
"""
|
|
found = set()
|
|
|
|
for node in ast.walk(tree):
|
|
# dict(label='X', description='Y', ...)
|
|
if (
|
|
isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Name)
|
|
and node.func.id == "dict"
|
|
):
|
|
for kw in node.keywords:
|
|
if kw.arg in EXTRACTED_KWARGS and _is_str_constant(kw.value):
|
|
val = kw.value.value.strip()
|
|
if val:
|
|
found.add(val)
|
|
|
|
# {"label": "X", "description": "Y", ...}
|
|
elif isinstance(node, ast.Dict):
|
|
for key, value in zip(node.keys, node.values):
|
|
if (
|
|
isinstance(key, ast.Constant)
|
|
and isinstance(key.value, str)
|
|
and key.value in EXTRACTED_KWARGS
|
|
and _is_str_constant(value)
|
|
):
|
|
val = value.value.strip()
|
|
if val:
|
|
found.add(val)
|
|
|
|
return sorted(found)
|
|
|
|
|
|
def _is_str_constant(node):
|
|
return isinstance(node, ast.Constant) and isinstance(node.value, str)
|
|
|
|
|
|
def _render(labels):
|
|
lines = [_HEADER.format(source=SOURCE_FILE)]
|
|
for label in labels:
|
|
# json.dumps produces a valid Python string literal: handles quotes,
|
|
# backslashes, and newlines correctly. ensure_ascii=False keeps
|
|
# Cyrillic / Azerbaijani / etc. readable in the generated file.
|
|
lines.append(f"_lt({json.dumps(label, ensure_ascii=False)})")
|
|
lines.append("")
|
|
return "\n".join(lines)
|