jey_erp/jey_erp/translate.py

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)