From 6c8ac0c4d4531caef362c1b6a0afc74c9dc9d7ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Such=C3=A1nek?= Date: Thu, 10 Sep 2026 06:57:45 +0200 Subject: [PATCH] feat(docworker, tdk): Support Document Template Locale --- .cspell/dictionary.txt | 24 ++ packages/dsw-database/CHANGELOG.md | 4 + .../dsw-database/dsw/database/database.py | 18 ++ packages/dsw-database/dsw/database/model.py | 4 + packages/dsw-document-worker/CHANGELOG.md | 18 ++ packages/dsw-document-worker/README.md | 1 + .../dsw/document_worker/consts.py | 18 +- .../dsw/document_worker/model/context.py | 30 ++- .../dsw/document_worker/plugins/specs.py | 12 + .../dsw/document_worker/pot.py | 231 ++++++++++++++++++ .../dsw/document_worker/templates/formats.py | 2 + .../dsw/document_worker/templates/locales.py | 107 ++++++++ .../document_worker/templates/steps/base.py | 25 ++ .../templates/steps/template.py | 58 ++--- .../document_worker/templates/templates.py | 19 ++ .../dsw/document_worker/worker.py | 38 ++- packages/dsw-document-worker/pyproject.toml | 2 +- .../support/DocumentContext.md | 13 + .../support/Translations.md | 147 +++++++++++ .../support/steps/jinja.md | 7 +- .../dsw-document-worker/tests/conftest.py | 51 ++++ .../tests/test_context_document.py | 44 ++++ .../tests/test_i18n_roundtrip.py | 115 +++++++++ .../dsw-document-worker/tests/test_locales.py | 136 +++++++++++ .../dsw-document-worker/tests/test_pot.py | 161 ++++++++++++ .../tests/test_steps_i18n.py | 88 +++++++ .../tests/test_steps_policies.py | 43 ++++ packages/dsw-models/CHANGELOG.md | 4 + .../dsw/models/document_template/metadata.py | 1 + packages/dsw-storage/CHANGELOG.md | 4 + packages/dsw-storage/dsw/storage/s3storage.py | 59 ++++- packages/dsw-tdk/CHANGELOG.md | 9 + packages/dsw-tdk/README.md | 1 + packages/dsw-tdk/dsw/tdk/cli.py | 23 ++ packages/dsw-tdk/dsw/tdk/consts.py | 8 +- packages/dsw-tdk/dsw/tdk/core.py | 13 + packages/dsw-tdk/dsw/tdk/model.py | 9 +- packages/dsw-tdk/dsw/tdk/pot.py | 89 +++++++ packages/dsw-tdk/dsw/tdk/utils.py | 9 + packages/dsw-tdk/dsw/tdk/validation.py | 1 + packages/dsw-tdk/pyproject.toml | 1 + .../test_example01/src/template.json.j2 | 4 +- .../fixtures/test_example01/template.json | 1 + packages/dsw-tdk/tests/test_cmd_new.py | 14 +- packages/dsw-tdk/tests/test_cmd_pot.py | 66 +++++ packages/dsw-tdk/tests/test_model_language.py | 43 ++++ uv.lock | 6 + 47 files changed, 1734 insertions(+), 47 deletions(-) create mode 100644 packages/dsw-document-worker/dsw/document_worker/pot.py create mode 100644 packages/dsw-document-worker/dsw/document_worker/templates/locales.py create mode 100644 packages/dsw-document-worker/support/Translations.md create mode 100644 packages/dsw-document-worker/tests/conftest.py create mode 100644 packages/dsw-document-worker/tests/test_context_document.py create mode 100644 packages/dsw-document-worker/tests/test_i18n_roundtrip.py create mode 100644 packages/dsw-document-worker/tests/test_locales.py create mode 100644 packages/dsw-document-worker/tests/test_pot.py create mode 100644 packages/dsw-document-worker/tests/test_steps_i18n.py create mode 100644 packages/dsw-document-worker/tests/test_steps_policies.py create mode 100644 packages/dsw-tdk/dsw/tdk/pot.py create mode 100644 packages/dsw-tdk/tests/test_cmd_pot.py create mode 100644 packages/dsw-tdk/tests/test_model_language.py diff --git a/.cspell/dictionary.txt b/.cspell/dictionary.txt index 92f54a8c..58363244 100644 --- a/.cspell/dictionary.txt +++ b/.cspell/dictionary.txt @@ -33,6 +33,25 @@ gridline gridlines pofile mofile +polib +pybabel +gettext +ngettext +pgettext +npgettext +msgid +msgids +msgstr +msgctxt +notrimmed +reindenting +reindented +urlize +unparseable +msginit +localedir +nplurals +pluralize awatch naturalsize ustar @@ -126,3 +145,8 @@ heighta xurl nosniff IPPROTO + +# Czech strings used in translation tests +Ahoj +Nazdar +Svete diff --git a/packages/dsw-database/CHANGELOG.md b/packages/dsw-database/CHANGELOG.md index 2c0d629d..4fd97d84 100644 --- a/packages/dsw-database/CHANGELOG.md +++ b/packages/dsw-database/CHANGELOG.md @@ -7,6 +7,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- `language` and `pot_file_ready` fields of `document_template` and a method to update the POT file flag + ### Changed - Read the `project` table column `knowledge_model_package_uuid` (renamed in the backend) diff --git a/packages/dsw-database/dsw/database/database.py b/packages/dsw-database/dsw/database/database.py index 4f146eb2..6df80f1b 100644 --- a/packages/dsw-database/dsw/database/database.py +++ b/packages/dsw-database/dsw/database/database.py @@ -50,6 +50,8 @@ class Database: UPDATE_DOCUMENT_FINISHED = ('UPDATE document SET finished_at = %s, state = %s, ' 'file_name = %s, content_type = %s, worker_log = %s, ' 'file_size = %s WHERE uuid = %s;') + UPDATE_DOCUMENT_TEMPLATE_POT_READY = ('UPDATE document_template SET pot_file_ready = %s ' + 'WHERE uuid = %s AND tenant_uuid = %s;') SELECT_TEMPLATE = ('SELECT * FROM document_template ' 'WHERE uuid = %s AND tenant_uuid = %s LIMIT 1;') SELECT_TEMPLATE_FORMATS = ('SELECT * FROM document_template_format ' @@ -382,6 +384,22 @@ def update_document_finished( ) return cursor.rowcount == 1 + @tenacity.retry( + reraise=True, + wait=tenacity.wait_exponential(multiplier=RETRY_QUERY_MULTIPLIER), + stop=tenacity.stop_after_attempt(RETRY_QUERY_TRIES), + before=tenacity.before_log(LOG, logging.DEBUG), + after=tenacity.after_log(LOG, logging.DEBUG), + ) + def update_document_template_pot_file_ready(self, *, template_uuid: str, + tenant_uuid: str, ready: bool) -> bool: + with self.conn_query.new_cursor() as cursor: + cursor.execute( + query=self.UPDATE_DOCUMENT_TEMPLATE_POT_READY, + params=(ready, template_uuid, tenant_uuid), + ) + return cursor.rowcount == 1 + @tenacity.retry( reraise=True, wait=tenacity.wait_exponential(multiplier=RETRY_QUERY_MULTIPLIER), diff --git a/packages/dsw-database/dsw/database/model.py b/packages/dsw-database/dsw/database/model.py index 6e7a8f6f..4bfde678 100644 --- a/packages/dsw-database/dsw/database/model.py +++ b/packages/dsw-database/dsw/database/model.py @@ -109,6 +109,8 @@ class DBDocumentTemplate: created_at: datetime updated_at: datetime tenant_uuid: str + language: str = 'en' + pot_file_ready: bool = False @property def is_draft(self): @@ -144,6 +146,8 @@ def from_dict_row(data: dict) -> DBDocumentTemplate: created_at=data['created_at'], updated_at=data['updated_at'], tenant_uuid=str(data.get('tenant_uuid', NULL_UUID)), + language=data.get('language', 'en'), + pot_file_ready=data.get('pot_file_ready', False), ) diff --git a/packages/dsw-document-worker/CHANGELOG.md b/packages/dsw-document-worker/CHANGELOG.md index f61cc2cb..964e24ab 100644 --- a/packages/dsw-document-worker/CHANGELOG.md +++ b/packages/dsw-document-worker/CHANGELOG.md @@ -7,10 +7,28 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Document template locales: a `.po` file attached to a document template version is applied at render time, so language is a parameter of document generation instead of a property of the format +- Generation of the POT file with translatable strings (new `generatePotFile` command function), stored in S3 and flagged by `document_template.pot_file_ready` +- `document.language` and `document.locale` in the document context +- Translations are available to all steps via `Step.before_render` and the `gettext` / `ngettext` / `pgettext` helpers (see [Translations](./support/Translations.md)) + ### Changed +- Update to DT metamodel 18.3 +- The `jinja2.ext.i18n` extension is always enabled for Jinja-powered steps - `extras.project` (and the deprecated `extras.questionnaire`) provide `knowledge_model_package_uuid` instead of `knowledge_package_uuid` +### Fixed + +- The `policy.urlize.extra_schemes` step option is applied to the `urlize.extra_schemes` Jinja policy instead of overwriting `truncate.leeway` + +### Removed + +- The `policy.ext.i18n.trimmed` step option; `{% trans %}` blocks are now always trimmed, since the trimming decides the `msgid` and the POT file is per document template while the option was per format. Use `{% trans notrimmed %}` where the whitespace matters. +- The experimental `i18n-dir`, `i18n-domain` and `i18n-lang` step options; they are now ignored and a template still using them renders untranslated. Use document template locales instead. + ## [4.34.0] diff --git a/packages/dsw-document-worker/README.md b/packages/dsw-document-worker/README.md index ba4cd77a..5b6a3ef5 100644 --- a/packages/dsw-document-worker/README.md +++ b/packages/dsw-document-worker/README.md @@ -26,6 +26,7 @@ DSW Document Worker technical documentation for template development: * [Document Context](./support/DocumentContext.md) * [Jinja Filters](./support/JinjaFilters.md) * [Jinja Tests](./support/JinjaTests.md) +* [Translations](./support/Translations.md) ## Docker diff --git a/packages/dsw-document-worker/dsw/document_worker/consts.py b/packages/dsw-document-worker/dsw/document_worker/consts.py index 8279c960..bdd1ed68 100644 --- a/packages/dsw-document-worker/dsw/document_worker/consts.py +++ b/packages/dsw-document-worker/dsw/document_worker/consts.py @@ -5,6 +5,7 @@ CMD_CHANNEL = 'doc_worker' CMD_COMPONENT = 'doc_worker' +CMD_FUNCTION_GENERATE_POT_FILE = 'generatePotFile' COMPONENT_NAME = 'Document Worker' DEFAULT_ENCODING = 'utf-8' EXIT_SUCCESS = 0 @@ -13,8 +14,23 @@ PLUGINS_ENTRYPOINT = 'dsw_document_worker_plugins' PROG_NAME = 'docworker' +JINJA_EXTENSIONS = ('jinja2.ext.do', 'jinja2.ext.loopcontrols') +JINJA_FILE_EXTENSIONS = ('.j2', '.jinja', '.jinja2', '.jnj') + +# Rendering and POT extraction must agree on this: the msgid of a {% trans %} +# block depends on it, and the POT file is per document template while a step +# option would be per format. +JINJA_I18N_TRIMMED = True + +DEFAULT_LANGUAGE = 'en' +DEFAULT_LOCALE_DOMAIN = 'default' +LOCALE_PO_FILE_NAME = 'translation.po' +LOCALE_MO_FILE_NAME = 'translation.mo' +LOCALE_STAMP_FILE_NAME = 'updated_at' +LOCALES_CACHE_DIR = '.locales' + CURRENT_METAMODEL_MAJOR = 18 -CURRENT_METAMODEL_MINOR = 2 +CURRENT_METAMODEL_MINOR = 3 try: __version__ = version(PACKAGE_NAME) diff --git a/packages/dsw-document-worker/dsw/document_worker/model/context.py b/packages/dsw-document-worker/dsw/document_worker/model/context.py index 8fea40b4..3815334e 100644 --- a/packages/dsw-document-worker/dsw/document_worker/model/context.py +++ b/packages/dsw-document-worker/dsw/document_worker/model/context.py @@ -1726,19 +1726,44 @@ def load(data: dict, **options): ) +class DocumentTemplateLocale: + + def __init__(self, *, uuid: str, name: str, code: str, + created_at: datetime, updated_at: datetime): + self.uuid = uuid + self.name = name + self.code = code + self.created_at = created_at + self.updated_at = updated_at + + @staticmethod + def load(data: dict, **options): + return DocumentTemplateLocale( + uuid=data['uuid'], + name=data['name'], + code=data['code'], + created_at=_datetime(data['createdAt']), + updated_at=_datetime(data['updatedAt']), + ) + + class Document: def __init__(self, *, uuid: str, name: str, document_template_uuid: str, format_uuid: str, - created_by: User | None, created_at: datetime): + created_by: User | None, created_at: datetime, language: str | None, + locale: DocumentTemplateLocale | None): self.uuid = uuid self.name = name self.document_template_uuid = document_template_uuid self.format_uuid = format_uuid self.created_by = created_by self.created_at = created_at + self.language = language + self.locale = locale @staticmethod def load(data: dict, **options): + locale_data = data.get('locale') return Document( uuid=data['uuid'], name=data['name'], @@ -1746,6 +1771,9 @@ def load(data: dict, **options): format_uuid=data['formatUuid'], created_by=User.load(data['createdBy'], **options), created_at=_datetime(data['createdAt']), + language=data.get('language'), + locale=(DocumentTemplateLocale.load(locale_data, **options) + if locale_data is not None else None), ) diff --git a/packages/dsw-document-worker/dsw/document_worker/plugins/specs.py b/packages/dsw-document-worker/dsw/document_worker/plugins/specs.py index 5a64db13..e4843c23 100644 --- a/packages/dsw-document-worker/dsw/document_worker/plugins/specs.py +++ b/packages/dsw-document-worker/dsw/document_worker/plugins/specs.py @@ -28,6 +28,12 @@ def provide_steps() -> dict[str, type[Step]]: `Step` class in the current implementation (use correct dsw-document-worker as a dependency). + Before the format pipeline runs, every step gets `before_render(render_ctx)` called + with the `RenderContext` of the document. The base implementation stores it, so a + step that does not override it still has `self.translations`, `self.language` and + the `self.gettext` / `self.ngettext` / `self.pgettext` helpers available. A step + that overrides `before_render` must call `super().before_render(render_ctx)`. + :return: a dictionary of steps that the plugin can execute """ return {} @@ -59,6 +65,12 @@ def enrich_jinja_env(jinja_env: Environment, options: dict[str, str]) -> None: all steps that are subclass of the `JinjaPoweredStep` (mainly the `jinja` step implemented in class `Jinja2Step`). + The `jinja2.ext.i18n` extension is always enabled and its `gettext`, `ngettext`, + `pgettext` and `npgettext` globals are re-installed before every rendering. The + plugin must not provide globals with these names; to work with translations, it + should override `Step.before_render` instead. + :param jinja_env: the Jinja environment to enrich :param options: the options provided to the step """ + diff --git a/packages/dsw-document-worker/dsw/document_worker/pot.py b/packages/dsw-document-worker/dsw/document_worker/pot.py new file mode 100644 index 00000000..b94feb49 --- /dev/null +++ b/packages/dsw-document-worker/dsw/document_worker/pot.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import dataclasses +import io +import logging +import re +import typing +import uuid + +import babel +import jinja2.exceptions +import jinja2.ext +from babel.messages.catalog import Catalog +from babel.messages.extract import DEFAULT_KEYWORDS, extract +from babel.messages.pofile import write_po + +from dsw.command_queue import CommandJobError + +from . import consts +from .context import Context + + +if typing.TYPE_CHECKING: + from dsw.database.model import DBDocumentTemplateFile, PersistentCommand + + +LOG = logging.getLogger(__name__) + +COMMENT_TAGS = ('TRANSLATORS:',) +EXTRACT_METHOD = typing.cast('typing.Any', jinja2.ext.babel_extract) +COORDINATE_PATTERN = re.compile(r'^[A-Za-z0-9._-]+$') +NO_WRAP = 0 +EXTRACT_OPTIONS = { + 'encoding': consts.DEFAULT_ENCODING, + 'extensions': ','.join(consts.JINJA_EXTENSIONS), + 'silent': 'false', + 'newstyle_gettext': 'true', + 'trimmed': str(consts.JINJA_I18N_TRIMMED).lower(), +} + + +@dataclasses.dataclass(frozen=True) +class PotFileRequest: + command_uuid: str + tenant_uuid: str + document_template_uuid: str + organization_id: str + template_id: str + version: str + language: str + + @property + def coordinates(self) -> str: + return f'{self.organization_id}:{self.template_id}:{self.version}' + + @property + def file_name(self) -> str: + return f'{self.organization_id}_{self.template_id}_{self.version}.pot' + + @staticmethod + def load(command: PersistentCommand) -> PotFileRequest: + body = command.body + template_uuid = body.get('documentTemplateUuid', '') + try: + template_uuid = str(uuid.UUID(str(template_uuid))) + except ValueError as e: + raise CommandJobError.create( + job_id=str(template_uuid), + message='Invalid document template UUID in command body', + try_again=False, + exc=e, + ) from e + coordinates = { + 'organizationId': str(body.get('organizationId', '')), + 'templateId': str(body.get('templateId', '')), + 'version': str(body.get('version', '')), + } + for name, value in coordinates.items(): + if COORDINATE_PATTERN.match(value) is None: + raise CommandJobError.create( + job_id=template_uuid, + message=f'Invalid value of "{name}" in command body', + try_again=False, + ) + return PotFileRequest( + command_uuid=command.uuid, + tenant_uuid=command.tenant_uuid, + document_template_uuid=template_uuid, + organization_id=coordinates['organizationId'], + template_id=coordinates['templateId'], + version=coordinates['version'], + language=str(body.get('language') or consts.DEFAULT_LANGUAGE), + ) + + +@dataclasses.dataclass +class ExtractionResult: + catalog: Catalog + failed_files: list[str] + + +def extract_messages(content: str) -> list[tuple]: + return list(extract( + method=EXTRACT_METHOD, + fileobj=io.BytesIO(content.encode(consts.DEFAULT_ENCODING)), + keywords=DEFAULT_KEYWORDS, + comment_tags=COMMENT_TAGS, + options=EXTRACT_OPTIONS, + )) + + +def make_catalog(*, project: str, version: str, language: str) -> Catalog: + locale: babel.Locale | None = None + try: + locale = babel.Locale.parse(language.replace('-', '_')) + except (ValueError, babel.UnknownLocaleError): + LOG.warning('Cannot parse language "%s" - POT file without locale info', language) + return Catalog( + locale=locale, + domain=consts.DEFAULT_LOCALE_DOMAIN, + project=project, + version=version, + charset=consts.DEFAULT_ENCODING, + fuzzy=False, + ) + + +def extract_catalog(files: list[DBDocumentTemplateFile], *, project: str, + version: str, language: str) -> ExtractionResult: + catalog = make_catalog(project=project, version=version, language=language) + failed_files = [] + for file in sorted(files, key=lambda f: f.file_name): + try: + messages = extract_messages(file.content) + except jinja2.exceptions.TemplateSyntaxError as e: + LOG.warning('Skipping file "%s" that cannot be parsed: %s', file.file_name, str(e)) + failed_files.append(file.file_name) + continue + for lineno, message, comments, context in messages: + catalog.add( + message, + None, + [(file.file_name, lineno)], + auto_comments=comments, + context=context, + ) + return ExtractionResult(catalog=catalog, failed_files=failed_files) + + +def render_pot_file(result: ExtractionResult) -> bytes: + lines = [f'# Translations template for {result.catalog.project}.'] + if result.failed_files: + lines.append(f'# Skipped files that could not be parsed: ' + f'{", ".join(result.failed_files)}') + result.catalog.header_comment = '\n'.join(lines) + '\n' + buffer = io.BytesIO() + write_po(buffer, result.catalog, width=NO_WRAP, omit_header=False, sort_output=True) + return buffer.getvalue() + + +class PotFileJob: + + def __init__(self, command: PersistentCommand): + self.ctx = Context.get() + self.rq = PotFileRequest.load(command) + self.ctx.tenant_uuid = self.rq.tenant_uuid + + def run(self): + template = self.ctx.app.db.fetch_template( + template_uuid=self.rq.document_template_uuid, + tenant_uuid=self.rq.tenant_uuid, + ) + if template is None: + LOG.warning('Document template %s not found, skipping POT file generation', + self.rq.document_template_uuid) + return + if template.coordinates != self.rq.coordinates: + LOG.warning('Command coordinates %s differ from the ones in DB (%s)', + self.rq.coordinates, template.coordinates) + files = self._fetch_template_files() + LOG.info('Extracting messages from %d file(s) of template %s', + len(files), self.rq.coordinates) + result = extract_catalog( + files, + project=self.rq.coordinates, + version=self.rq.version, + language=self.rq.language, + ) + LOG.info('Extracted %d message(s), %d file(s) skipped', + len(result.catalog), len(result.failed_files)) + self._store_pot_file(render_pot_file(result)) + self._mark_pot_file_ready() + + def _fetch_template_files(self) -> list[DBDocumentTemplateFile]: + db_files = self.ctx.app.db.fetch_template_files( + template_uuid=self.rq.document_template_uuid, + tenant_uuid=self.rq.tenant_uuid, + ) + return [f for f in db_files + if f.file_name.endswith(consts.JINJA_FILE_EXTENSIONS)] + + def _store_pot_file(self, data: bytes): + try: + self.ctx.app.s3.ensure_bucket() + self.ctx.app.s3.store_document_template_pot( + tenant_uuid=self.rq.tenant_uuid, + template_uuid=self.rq.document_template_uuid, + file_name=self.rq.file_name, + data=data, + ) + except Exception as e: + raise CommandJobError.create( + job_id=self.rq.document_template_uuid, + message='Failed to store the POT file in S3', + exc=e, + ) from e + LOG.info('POT file %s stored in S3', self.rq.file_name) + + def _mark_pot_file_ready(self): + try: + self.ctx.app.db.update_document_template_pot_file_ready( + template_uuid=self.rq.document_template_uuid, + tenant_uuid=self.rq.tenant_uuid, + ready=True, + ) + except Exception as e: + raise CommandJobError.create( + job_id=self.rq.document_template_uuid, + message='Failed to mark the POT file as ready', + exc=e, + ) from e diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/formats.py b/packages/dsw-document-worker/dsw/document_worker/templates/formats.py index e48db048..ea8cb6b8 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/formats.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/formats.py @@ -67,6 +67,8 @@ def requires_via_extras(self, requirement: str) -> bool: for step in self.steps) def execute(self, context: dict) -> DocumentFile: + for step in self.steps: + step.before_render(self.template.render_ctx) result = self.steps[0].execute_first(context) for step in self.steps[1:]: if result is not None: diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/locales.py b/packages/dsw-document-worker/dsw/document_worker/templates/locales.py new file mode 100644 index 00000000..e2dfe784 --- /dev/null +++ b/packages/dsw-document-worker/dsw/document_worker/templates/locales.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import dataclasses +import gettext +import logging +import typing +import uuid + +import polib + +from .. import consts +from ..context import Context + + +if typing.TYPE_CHECKING: + from pathlib import Path + + +LOG = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class TemplateLocale: + uuid: str + name: str + code: str + updated_at: str + + @staticmethod + def load(data: dict | None) -> TemplateLocale | None: + if not isinstance(data, dict): + return None + try: + locale_uuid = str(uuid.UUID(str(data['uuid']))) + except (KeyError, ValueError): + LOG.warning('Ignoring locale without a valid UUID') + return None + return TemplateLocale( + uuid=locale_uuid, + name=str(data.get('name', '')), + code=str(data.get('code', '')), + updated_at=str(data.get('updatedAt', '')), + ) + + +@dataclasses.dataclass +class RenderContext: + translations: gettext.NullTranslations + language: str | None = None + locale: TemplateLocale | None = None + + @staticmethod + def null(language: str | None = None) -> RenderContext: + return RenderContext( + translations=gettext.NullTranslations(), + language=language, + ) + + +class LocaleLoader: + + def __init__(self, *, cache_dir: Path, tenant_uuid: str): + self.cache_dir = cache_dir + self.tenant_uuid = tenant_uuid + + def load(self, locale: TemplateLocale) -> gettext.NullTranslations: + locale_dir = self.cache_dir / locale.uuid + mo_path = locale_dir / consts.LOCALE_MO_FILE_NAME + stamp_path = locale_dir / consts.LOCALE_STAMP_FILE_NAME + if not self._is_cached(mo_path, stamp_path, locale): + locale_dir.mkdir(parents=True, exist_ok=True) + stamp_path.unlink(missing_ok=True) + self._prepare_mo_file(locale, mo_path) + stamp_path.write_text(locale.updated_at, encoding=consts.DEFAULT_ENCODING) + with mo_path.open('rb') as fp: + return gettext.GNUTranslations(fp) + + @staticmethod + def _is_cached(mo_path: Path, stamp_path: Path, locale: TemplateLocale) -> bool: + if not mo_path.exists() or not stamp_path.exists(): + return False + return stamp_path.read_text(encoding=consts.DEFAULT_ENCODING) == locale.updated_at + + def _prepare_mo_file(self, locale: TemplateLocale, mo_path: Path): + if self._download(locale, consts.LOCALE_MO_FILE_NAME, mo_path): + LOG.debug('Using compiled locale %s from S3', locale.uuid) + return + po_path = mo_path.parent / consts.LOCALE_PO_FILE_NAME + if not self._download(locale, consts.LOCALE_PO_FILE_NAME, po_path): + raise RuntimeError(f'Cannot download locale file of {locale.uuid}') + polib.pofile(str(po_path)).save_as_mofile(str(mo_path)) + LOG.debug('Compiled locale %s from PO file', locale.uuid) + Context.get().app.s3.store_document_template_locale( + tenant_uuid=self.tenant_uuid, + locale_uuid=locale.uuid, + file_name=consts.LOCALE_MO_FILE_NAME, + content_type='application/octet-stream', + data=mo_path.read_bytes(), + ) + + def _download(self, locale: TemplateLocale, file_name: str, target_path: Path) -> bool: + return Context.get().app.s3.download_document_template_locale( + tenant_uuid=self.tenant_uuid, + locale_uuid=locale.uuid, + file_name=file_name, + target_path=target_path, + ) diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/steps/base.py b/packages/dsw-document-worker/dsw/document_worker/templates/steps/base.py index 1771866d..b5f34389 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/steps/base.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/steps/base.py @@ -2,8 +2,12 @@ import typing +from ..locales import RenderContext + if typing.TYPE_CHECKING: + from gettext import NullTranslations + from ...documents import DocumentFile @@ -26,6 +30,7 @@ def __init__(self, template, options: dict[str, str]): extras_str: str = self.options.get(self.OPTION_EXTRAS, '') self.extras: set[str] = set(extras_str.split(',')) + self.render_ctx = RenderContext.null() @staticmethod def initialize_step(): @@ -34,6 +39,26 @@ def initialize_step(): def requires_via_extras(self, requirement: str) -> bool: return requirement in self.extras + def before_render(self, render_ctx: RenderContext) -> None: + self.render_ctx = render_ctx + + @property + def translations(self) -> NullTranslations: + return self.render_ctx.translations + + @property + def language(self) -> str | None: + return self.render_ctx.language + + def gettext(self, message: str) -> str: + return self.translations.gettext(message) + + def ngettext(self, singular: str, plural: str, n: int) -> str: + return self.translations.ngettext(singular, plural, n) + + def pgettext(self, context: str, message: str) -> str: + return self.translations.pgettext(context, message) + def execute_first(self, context: dict) -> DocumentFile: return self.raise_exc('Called execute_follow on Step class') diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/steps/template.py b/packages/dsw-document-worker/dsw/document_worker/templates/steps/template.py index 8321c5bc..2f94e2b0 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/steps/template.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/steps/template.py @@ -1,7 +1,6 @@ from __future__ import annotations import datetime -import gettext import json import typing import zoneinfo @@ -10,7 +9,7 @@ import jinja2.exceptions import rdflib -from ...consts import DEFAULT_ENCODING +from ...consts import DEFAULT_ENCODING, JINJA_EXTENSIONS, JINJA_I18N_TRIMMED from ...context import Context from ...documents import DocumentFile, FileFormat, FileFormats from ...model.context import ProjectFile @@ -22,6 +21,12 @@ from .base import Step, register_step +if typing.TYPE_CHECKING: + from gettext import NullTranslations + + from ..locales import RenderContext + + class JSONStep(Step): NAME = 'json' OUTPUT_FORMAT = FileFormats.JSON @@ -39,34 +44,27 @@ def execute_follow(self, document: DocumentFile, context: dict) -> DocumentFile: class JinjaPoweredStep(Step): OPTION_JINJA_EXT = 'jinja-ext' - OPTION_I18N_DIR = 'i18n-dir' - OPTION_I18N_DOMAIN = 'i18n-domain' - OPTION_I18N_LANG = 'i18n-lang' def __init__(self, template, options): super().__init__(template, options) self.jinja_ext = frozenset( opt.strip() for opt in self.options.get(self.OPTION_JINJA_EXT, '').split(',') ) - self.i18n_dir = self.options.get(self.OPTION_I18N_DIR, None) - self.i18n_domain = self.options.get(self.OPTION_I18N_DOMAIN, 'default') - self.i18n_lang = self.options.get(self.OPTION_I18N_LANG, None) try: self.j2_env = JinjaEnvironment( loader=jinja2.FileSystemLoader(searchpath=template.template_dir), extensions=[ - 'jinja2.ext.do', - 'jinja2.ext.loopcontrols', + *JINJA_EXTENSIONS, + 'jinja2.ext.i18n', ], autoescape=True, ) - if 'i18n' in self.jinja_ext: - self._add_j2_i18n(template) if 'debug' in self.jinja_ext: self.j2_env.add_extension('jinja2.ext.debug') self._apply_policies(options) self._add_j2_enhancements() + self._install_translations(None) Context.get().app.pm.hook.enrich_jinja_env( jinja_env=self.j2_env, @@ -90,6 +88,7 @@ def _apply_policies(self, options: dict): # https://jinja.palletsprojects.com/en/3.0.x/api/#policies policies: dict[str, typing.Any] = { 'policy.urlize.target': '_blank', + 'ext.i18n.trimmed': JINJA_I18N_TRIMMED, 'json.dumps_kwargs': { 'allow_nan': False, 'ensure_ascii': False, @@ -102,11 +101,7 @@ def _apply_policies(self, options: dict): if 'policy.urlize.target' in options: policies['urlize.target'] = options['policy.urlize.target'] if 'policy.urlize.extra_schemes' in options: - values = options['policy.urlize.extra_schemes'].split(',') - policies['truncate.leeway'] = values - if 'policy.ext.i18n.trimmed' in options: - value = options['policy.ext.i18n.trimmed'].lower() == 'true' - policies['ext.i18n.trimmed'] = value + policies['urlize.extra_schemes'] = options['policy.urlize.extra_schemes'].split(',') for key in options: if not key.startswith('policy.json.dumps_kwargs.'): continue @@ -117,23 +112,20 @@ def _apply_policies(self, options: dict): policies['json.dumps_kwargs'][name] = options[key] self.j2_env.policies.update(policies) - def _add_j2_i18n(self, template): + def before_render(self, render_ctx: RenderContext) -> None: + super().before_render(render_ctx) + self._install_translations(render_ctx.translations) + + def _install_translations(self, translations: NullTranslations | None): # https://jinja.palletsprojects.com/en/3.1.x/extensions/#i18n-extension - self.j2_env.add_extension('jinja2.ext.i18n') - if self.i18n_dir is not None and self.i18n_lang is not None: - locale_path = template.template_dir / self.i18n_dir - translations = gettext.translation( - domain=self.i18n_domain, - localedir=locale_path, - languages=(lang.strip() for lang in self.i18n_lang.split(',')), - ) - install_translations = getattr(self.j2_env, 'install_gettext_translations', None) - if callable(install_translations): - install_translations(translations, newstyle=True) - else: - install_translations = getattr(self.j2_env, 'install_null_translations', None) - if callable(install_translations): - install_translations(newstyle=True) + if translations is None: + install_null = getattr(self.j2_env, 'install_null_translations', None) + if callable(install_null): + install_null(newstyle=True) + return + install = getattr(self.j2_env, 'install_gettext_translations', None) + if callable(install): + install(translations, newstyle=True) @property def _j2_filters(self) -> typing.MutableMapping[str, typing.Any]: diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/templates.py b/packages/dsw-document-worker/dsw/document_worker/templates/templates.py index 950e7770..58daa5f8 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/templates.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/templates.py @@ -10,6 +10,7 @@ from .. import consts from ..context import Context from .formats import Format +from .locales import LocaleLoader, RenderContext, TemplateLocale from .steps.base import Step, register_step @@ -87,6 +88,11 @@ def __init__(self, tenant_uuid: str, template_dir: Path, self.formats: dict[str, Format] = {} self.project_uuid: str | None = None + self.render_ctx = RenderContext.null() + self._locale_loader = LocaleLoader( + cache_dir=template_dir / consts.LOCALES_CACHE_DIR, + tenant_uuid=tenant_uuid, + ) def raise_exc(self, message: str): raise TemplateError(self.template_uuid, message) @@ -269,6 +275,19 @@ def update_template(self, db_template: TemplateComposite): self.update_template_files(db_template.files) self.update_template_assets(db_template.assets) + def prepare_locale(self, *, language: str | None, locale: TemplateLocale | None): + if locale is None: + LOG.info('No locale for template %s - using null translations', self.template_uuid) + self.render_ctx = RenderContext.null(language=language) + return + LOG.info('Loading locale %s (%s) for template %s', + locale.uuid, locale.code, self.template_uuid) + self.render_ctx = RenderContext( + translations=self._locale_loader.load(locale), + language=language, + locale=locale, + ) + def prepare_format(self, format_uuid: str): for format_meta in self.db_template.template.formats: if format_uuid == format_meta.get(consts.FormatField.UUID): diff --git a/packages/dsw-document-worker/dsw/document_worker/worker.py b/packages/dsw-document-worker/dsw/document_worker/worker.py index 0c917844..6707d990 100644 --- a/packages/dsw-document-worker/dsw/document_worker/worker.py +++ b/packages/dsw-document-worker/dsw/document_worker/worker.py @@ -18,7 +18,9 @@ from .documents import DocumentFile, DocumentNameGiver from .exceptions import DocumentNotFoundError, JobError, create_job_error from .limits import LimitsEnforcer +from .pot import PotFileJob from .templates import Format, Template, TemplateRegistry +from .templates.locales import TemplateLocale from .utils import byte_size_format, check_metamodel_version @@ -243,6 +245,15 @@ def check_compliance(self): metamodel_version=str(self.doc_context.get('metamodelVersion', '0')), ) + @handle_job_step('Failed to prepare document locale') + def prepare_locale(self): + SentryReporter.set_tags(phase='locale') + doc_data = self.doc_context.get('document') or {} + self.safe_template.prepare_locale( + language=doc_data.get('language'), + locale=TemplateLocale.load(doc_data.get('locale')), + ) + @handle_job_step('Failed to build final document') def build_document(self): LOG.info('Building document by rendering template with context') @@ -335,6 +346,7 @@ def _run(self): self.get_document() self.prepare_template() + self.prepare_locale() self.build_document() self.store_document() @@ -470,6 +482,29 @@ def run_once(self): queue.run_once() def work(self, command: PersistentCommand): + if command.function == consts.CMD_FUNCTION_GENERATE_POT_FILE: + self._work_pot_file(command) + return + self._work_document(command) + + @staticmethod + def _work_pot_file(command: PersistentCommand): + Context.get().update_trace_id(command.uuid) + SentryReporter.set_tags( + command_uuid=command.uuid, + tenant_uuid=command.tenant_uuid, + phase='pot', + ) + LOG.info('Running POT file job #%s', command.uuid) + PotFileJob(command=command).run() + SentryReporter.set_tags( + command_uuid='-', + tenant_uuid='-', + phase='done', + ) + Context.get().reset_ids() + + def _work_document(self, command: PersistentCommand): document_uuid = command.body['document']['uuid'] Context.get().update_trace_id(command.uuid) Context.get().update_document_id(document_uuid) @@ -489,8 +524,7 @@ def work(self, command: PersistentCommand): document_uuid='-', phase='done', ) - Context.get().update_trace_id('-') - Context.get().update_document_id('-') + Context.get().reset_ids() def process_exception(self, e: BaseException): LOG.info('Failed with exception') diff --git a/packages/dsw-document-worker/pyproject.toml b/packages/dsw-document-worker/pyproject.toml index 678c1f56..e94296eb 100644 --- a/packages/dsw-document-worker/pyproject.toml +++ b/packages/dsw-document-worker/pyproject.toml @@ -48,7 +48,7 @@ artifacts = ["dsw/*/build_info.py"] artifacts = ["dsw/*/build_info.py"] [tool.hatch.metadata.hooks.uv-dynamic-versioning] -dependencies = ["click", "Jinja2", "Markdown", "MarkupSafe", "nh3", "panflute", "pathvalidate", "pluggy", "pymdown-extensions", "python-dateutil", "python-slugify", "rdflib", "rdflib-jsonld", "requests", "sentry-sdk", "tenacity", "weasyprint", "XlsxWriter", "dsw-command-queue=={{ version }}", "dsw-config=={{ version }}", "dsw-database=={{ version }}", "dsw-storage=={{ version }}"] +dependencies = ["Babel", "click", "Jinja2", "Markdown", "MarkupSafe", "nh3", "panflute", "pathvalidate", "pluggy", "polib", "pymdown-extensions", "python-dateutil", "python-slugify", "rdflib", "rdflib-jsonld", "requests", "sentry-sdk", "tenacity", "weasyprint", "XlsxWriter", "dsw-command-queue=={{ version }}", "dsw-config=={{ version }}", "dsw-database=={{ version }}", "dsw-storage=={{ version }}"] [tool.uv-dynamic-versioning] vcs = "git" diff --git a/packages/dsw-document-worker/support/DocumentContext.md b/packages/dsw-document-worker/support/DocumentContext.md index 7b1c1142..68d8a265 100644 --- a/packages/dsw-document-worker/support/DocumentContext.md +++ b/packages/dsw-document-worker/support/DocumentContext.md @@ -48,6 +48,19 @@ Aliases: ### Document * `uuid` (`str`) +* `name` (`str`) +* `document_template_uuid` (`str`) +* `format_uuid` (`str`) +* `language` (`Optional[str]`) - language requested for the document, `None` when none was selected +* `locale` (`Optional[`[`DocumentTemplateLocale`](#documenttemplatelocale)`]`) - locale used for the translations, see [Translations](./Translations.md) +* `created_by` (`Optional[`[`User`](#user)`]`) +* `created_at` (`datetime`) + +### DocumentTemplateLocale + +* `uuid` (`str`) +* `name` (`str`) +* `code` (`str`) - language code from the `Language` header of the locale PO file * `created_at` (`datetime`) * `updated_at` (`datetime`) diff --git a/packages/dsw-document-worker/support/Translations.md b/packages/dsw-document-worker/support/Translations.md new file mode 100644 index 00000000..9a17a014 --- /dev/null +++ b/packages/dsw-document-worker/support/Translations.md @@ -0,0 +1,147 @@ +# Translations + +A document template can be translated without releasing a new version. The template +itself declares its own `language` (the language its source strings are written in) and +the strings marked in its Jinja files are collected into a POT file. That POT file is the +starting point for translators; each finished translation is uploaded back to the Wizard +as a **document template locale** (a `.po` file attached to a released document template). + +Language is then a parameter of document generation that is orthogonal to format — one +`HTML` format can produce an English, Czech or Dutch document, instead of needing three +formats. + +## Marking strings for translation + +Strings are marked using the [Jinja2 i18n extension](https://jinja.palletsprojects.com/en/3.1.x/extensions/#i18n-extension), +which is **always enabled** (there is no option to turn it on or off). + +```jinja +{% trans %}Data Management Plan{% endtrans %} + +{{ _('Data Management Plan') }} + +{{ gettext('Data Management Plan') }} + +{% trans count = ctx.answers|length %} +There is {{ count }} answer. +{% pluralize %} +There are {{ count }} answers. +{% endtrans %} + +{{ ngettext('%(num)d answer', '%(num)d answers', answers|length) }} + +{{ pgettext('menu', 'Open') }} +``` + +A comment for the translator is a Jinja comment starting with `TRANSLATORS:` placed +directly above the marked string: + +```jinja +{# TRANSLATORS: heading of the first chapter #} +

{% trans %}Introduction{% endtrans %}

+``` + +### Whitespace + +`{% trans %}` blocks are **always trimmed**: leading and trailing whitespace is +dropped and runs of whitespace inside the block collapse to a single space. So this + +```jinja +

+ {% trans %} + Data Management Plan + {% endtrans %} +

+``` + +produces the `msgid` `Data Management Plan` rather than one that carries the +template's indentation. That is what keeps a translation working when somebody +re-indents the template later — without it, every re-indentation would change every +`msgid` in the file and orphan the uploaded locales. + +Where the whitespace is part of the string — inside `
`, or in a plain-text or
+Markdown format — opt out per block:
+
+```jinja
+
{% trans notrimmed %}
+  line one
+  line two
+{% endtrans %}
+``` + +`notrimmed` lives in the template source, so the POT file and the rendering agree +about it automatically. + +## The POT file + +The POT file is generated by the Document Worker after a document template draft is +released and after a bundle import, and it is offered for download in the Wizard. + +* Only **template files** are scanned — those with the `.j2`, `.jinja`, `.jinja2` or + `.jnj` extension. Assets (images, stylesheets, ...) are never scanned. +* Each file is parsed on its own, so a file that cannot be parsed does not lose the whole + catalog: it is skipped and listed in the header comment of the POT file. +* Messages are sorted, so re-releasing an unchanged template produces the same catalog. +* The `Language` and `Plural-Forms` headers are derived from the template's own + `language`. + +The same POT file can be produced locally from a template project with +[`dsw-tdk`](../../dsw-tdk/README.md): + +```bash +$ dsw-tdk pot +``` + +## Translating + +The POT file is a normal gettext catalog, so the usual tooling applies — `msginit`, +[Poedit](https://poedit.net), Weblate, or any translation service that accepts PO files. + +```bash +$ msginit --input=template.pot --locale=cs --output=cs.po +``` + +The Wizard reads the locale code from the `Language:` header of the uploaded PO file, so +that header must be filled in correctly. + +## Rendering with a locale + +When a document is generated with a language that has a locale, the Document Worker +downloads the PO file, compiles it, and installs the translations for **all** steps of +the format before rendering. A compiled catalog is cached locally under the template +working directory and in S3, so only the first document of a given locale pays for the +download and compilation. If `WORKDIR_PATH` points to a container `tmpfs`, that first +download happens again after every restart. + +If the locale cannot be downloaded or is corrupt, document generation **fails** rather +than silently producing an untranslated document. + +Steps other than `jinja` also have access to the catalog through the `Step` interface +(`self.gettext`, `self.ngettext`, `self.pgettext`, `self.translations` and +`self.language`), so a custom plugin step can translate its own output. + +## Document context + +The language and the locale used for the document are part of the document context (see +[Document Context](./DocumentContext.md)): + +```jinja +{{ ctx.document.language }} {# e.g. "cs", or None #} +{{ ctx.document.locale.code }} {# e.g. "cs", or None when no locale is used #} +{{ ctx.document.locale.name }} +``` + +## Migrating from `i18n-dir` + +Before document template locales, translations were shipped inside the template package +and selected per format using the experimental `jinja-ext: i18n` plus `i18n-dir`, +`i18n-domain` and `i18n-lang` step options. **Those options have been removed** and are +now ignored, so a template that still uses them renders untranslated. + +To migrate: + +1. Drop the `jinja-ext`, `i18n-dir`, `i18n-domain` and `i18n-lang` options and merge the + per-language formats back into one format per output type. +2. Set the template's `language` to the language of the source strings. +3. Release the template, download the generated POT file, and upload the existing + catalogs as document template locales. diff --git a/packages/dsw-document-worker/support/steps/jinja.md b/packages/dsw-document-worker/support/steps/jinja.md index f1e2bc0b..f0baf35b 100644 --- a/packages/dsw-document-worker/support/steps/jinja.md +++ b/packages/dsw-document-worker/support/steps/jinja.md @@ -18,11 +18,16 @@ Results to a file of specified type (via `content-type` option) and file extensi * `template` = path to template file to be rendered * `content-type` = MIME type of resulting file * `extension` = file extension for the produced file (without leading dot) +* `jinja-ext` = comma-separated list of optional Jinja2 extensions to enable; only `debug` is supported + +Other `policy.*` options map to [Jinja2 policies](https://jinja.palletsprojects.com/en/3.1.x/api/#policies): `policy.truncate.leeway`, `policy.urlize.rel`, `policy.urlize.target`, `policy.urlize.extra_schemes`, and `policy.json.dumps_kwargs.`. ## Notes * All paths (e.g. for `import` or `extends` in Jinja2 templates are relative from the template root, i.e. directory with `template.json`). -* The [`do` Jinja2 extension](https://jinja.palletsprojects.com/en/3.0.x/extensions/#expression-statement) is enabled. +* The [`do`](https://jinja.palletsprojects.com/en/3.1.x/extensions/#expression-statement), [`loopcontrols`](https://jinja.palletsprojects.com/en/3.1.x/extensions/#loop-controls) and [`i18n`](https://jinja.palletsprojects.com/en/3.1.x/extensions/#i18n-extension) Jinja2 extensions are always enabled. +* `{% trans %}` blocks are **always trimmed** and the `policy.ext.i18n.trimmed` option has been removed. Trimming decides the `msgid`, and the POT file is generated per document template while this option was per format, so no single value could have been correct for every format's catalog. Use `{% trans notrimmed %}` where the whitespace matters. +* The experimental `i18n-dir`, `i18n-domain` and `i18n-lang` options have been **removed** and are ignored. Translations are provided per document as [document template locales](../Translations.md), not shipped inside the template. * Using file extension `.j2` or `.jinja2` for templates is just a convention. * The document context is provided in `ctx` variable, other variables, filters, and tests are documented in other documents. * If enabled via `templates..requests.enabled` in the worker configuration, a `requests` object is available for making HTTP requests from the template. Enabling it means that the template can reach any host the worker can reach, and the response can be embedded in the document. Therefore: diff --git a/packages/dsw-document-worker/tests/conftest.py b/packages/dsw-document-worker/tests/conftest.py new file mode 100644 index 00000000..4c3af3a6 --- /dev/null +++ b/packages/dsw-document-worker/tests/conftest.py @@ -0,0 +1,51 @@ +import pathlib +import types + +import pytest + +from dsw.document_worker.config import TemplatesConfig +from dsw.document_worker.context import Context + + +class FakeS3: + """Minimal in-memory stand-in for the document template locale storage.""" + + def __init__(self): + self.objects: dict[str, bytes] = {} + self.downloads: list[str] = [] + self.stored: list[str] = [] + + @staticmethod + def _key(locale_uuid: str, file_name: str) -> str: + return f'{locale_uuid}/{file_name}' + + def download_document_template_locale(self, *, tenant_uuid, locale_uuid, + file_name, target_path) -> bool: + key = self._key(locale_uuid, file_name) + self.downloads.append(key) + data = self.objects.get(key) + if data is None: + return False + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(data) + return True + + def store_document_template_locale(self, *, tenant_uuid, locale_uuid, + file_name, content_type, data): + key = self._key(locale_uuid, file_name) + self.stored.append(key) + self.objects[key] = data + + +@pytest.fixture +def fake_context(tmp_path: pathlib.Path): + original = Context._instance + s3 = FakeS3() + Context.initialize( + db=None, + s3=s3, + config=types.SimpleNamespace(templates=TemplatesConfig(templates=[])), + workdir=tmp_path, + ) + yield types.SimpleNamespace(s3=s3, workdir=tmp_path) + Context._instance = original diff --git a/packages/dsw-document-worker/tests/test_context_document.py b/packages/dsw-document-worker/tests/test_context_document.py new file mode 100644 index 00000000..87d2cd9b --- /dev/null +++ b/packages/dsw-document-worker/tests/test_context_document.py @@ -0,0 +1,44 @@ +from dsw.document_worker.model.context import Document + + +BASE_DATA = { + 'uuid': '55555555-5555-5555-5555-555555555555', + 'name': 'My Document', + 'documentTemplateUuid': '33333333-3333-3333-3333-333333333333', + 'formatUuid': '66666666-6666-6666-6666-666666666666', + 'createdBy': None, + 'createdAt': '2026-01-01T00:00:00Z', +} + + +def test_load_without_language_and_locale(): + doc = Document.load(dict(BASE_DATA)) + assert doc.language is None + assert doc.locale is None + + +def test_load_with_null_locale(): + doc = Document.load({**BASE_DATA, 'language': 'cs', 'locale': None}) + assert doc.language == 'cs' + assert doc.locale is None + + +def test_load_with_locale(): + doc = Document.load({ + **BASE_DATA, + 'language': 'cs', + 'locale': { + 'uuid': '44444444-4444-4444-4444-444444444444', + 'name': 'Czech', + 'code': 'cs', + 'createdAt': '2026-01-01T00:00:00Z', + 'updatedAt': '2026-02-02T00:00:00Z', + }, + }) + assert doc.language == 'cs' + assert doc.locale is not None + assert doc.locale.uuid == '44444444-4444-4444-4444-444444444444' + assert doc.locale.name == 'Czech' + assert doc.locale.code == 'cs' + assert doc.locale.created_at.year == 2026 + assert doc.locale.updated_at.month == 2 diff --git a/packages/dsw-document-worker/tests/test_i18n_roundtrip.py b/packages/dsw-document-worker/tests/test_i18n_roundtrip.py new file mode 100644 index 00000000..4227cf90 --- /dev/null +++ b/packages/dsw-document-worker/tests/test_i18n_roundtrip.py @@ -0,0 +1,115 @@ +"""Extraction and rendering must agree on every msgid. + +The POT file is generated per document template while a Jinja environment is +built per step, so any setting that changes a msgid has to be fixed for both. +These tests render with a catalog built from what the extractor actually +produced: if the two sides ever disagree, the lookup misses and the assertion +on the translated output fails. +""" +import gettext +import pathlib +import types + +import polib +import pytest + +from dsw.document_worker.pot import extract_messages +from dsw.document_worker.templates.locales import RenderContext +from dsw.document_worker.templates.steps.template import Jinja2Step + + +ROOT_FILE = 'src/root.j2' + +SOURCE = """

+ {% trans %} + Hello there + {% endtrans %} +

+
{% trans notrimmed %}
+  keep
+  this
+{% endtrans %}
+

+ {% trans count = ctx['n'] %} + one item + {% pluralize %} + many items + {% endtrans %} +

+""" + + +def build_catalog(source: str, translate) -> gettext.GNUTranslations: + """Compile a catalog keyed by the msgids the extractor produced.""" + po = polib.POFile() + po.metadata = { + 'Content-Type': 'text/plain; charset=utf-8', + 'Language': 'cs', + 'Plural-Forms': 'nplurals=2; plural=(n != 1);', + } + for _lineno, message, _comments, _context in extract_messages(source): + if isinstance(message, tuple): + po.append(polib.POEntry( + msgid=message[0], + msgid_plural=message[1], + msgstr_plural={0: translate(message[0]), 1: translate(message[1])}, + )) + else: + po.append(polib.POEntry(msgid=message, msgstr=translate(message))) + return po + + +@pytest.fixture +def step(fake_context, tmp_path: pathlib.Path) -> Jinja2Step: + root = tmp_path / ROOT_FILE + root.parent.mkdir(parents=True, exist_ok=True) + root.write_text(SOURCE, encoding='utf-8') + template = types.SimpleNamespace( + template_dir=tmp_path, + coordinates='org:tid:1.0.0', + ) + return Jinja2Step(template, {'template': ROOT_FILE}) + + +def install(step: Jinja2Step, tmp_path: pathlib.Path, translate) -> None: + mo_path = tmp_path / 'messages.mo' + build_catalog(SOURCE, translate).save_as_mofile(str(mo_path)) + with mo_path.open('rb') as fp: + step.before_render(RenderContext(translations=gettext.GNUTranslations(fp), + language='cs')) + + +def render(step: Jinja2Step, **ctx) -> str: + return step.execute_first(ctx).content.decode('utf-8') + + +def test_extracted_msgids_are_trimmed(): + messages = [m for _, m, _, _ in extract_messages(SOURCE)] + assert 'Hello there' in messages + assert ('one item', 'many items') in messages + + +def test_notrimmed_block_keeps_its_whitespace(): + messages = [m for _, m, _, _ in extract_messages(SOURCE)] + assert '\n keep\n this\n' in messages + + +def test_every_extracted_msgid_is_found_at_render_time(step, tmp_path): + install(step, tmp_path, lambda msgid: f'<{msgid}>') + output = render(step, n=1) + assert '' in output + assert '<\n keep\n this\n>' in output + assert '' in output + + +def test_plural_lookup_agrees(step, tmp_path): + install(step, tmp_path, lambda msgid: f'<{msgid}>') + assert '' in render(step, n=5) + + +def test_reindenting_the_template_keeps_the_msgid(fake_context, tmp_path): + reindented = SOURCE.replace(' Hello there', ' Hello there') + assert reindented != SOURCE + original = [m for _, m, _, _ in extract_messages(SOURCE)] + changed = [m for _, m, _, _ in extract_messages(reindented)] + assert original == changed diff --git a/packages/dsw-document-worker/tests/test_locales.py b/packages/dsw-document-worker/tests/test_locales.py new file mode 100644 index 00000000..994b308f --- /dev/null +++ b/packages/dsw-document-worker/tests/test_locales.py @@ -0,0 +1,136 @@ +import gettext + +import polib +import pytest + +from dsw.document_worker import consts +from dsw.document_worker.templates.locales import LocaleLoader, TemplateLocale + + +LOCALE_UUID = '44444444-4444-4444-4444-444444444444' +TENANT_UUID = '22222222-2222-2222-2222-222222222222' + + +def make_locale(updated_at='2026-01-01T00:00:00Z') -> TemplateLocale: + return TemplateLocale( + uuid=LOCALE_UUID, + name='Czech', + code='cs', + updated_at=updated_at, + ) + + +def make_po_bytes(translations: dict[str, str]) -> bytes: + po = polib.POFile() + po.metadata = { + 'Content-Type': 'text/plain; charset=utf-8', + 'Language': 'cs', + } + for msgid, msgstr in translations.items(): + po.append(polib.POEntry(msgid=msgid, msgstr=msgstr)) + return str(po).encode('utf-8') + + +@pytest.fixture +def loader(fake_context): + return LocaleLoader( + cache_dir=fake_context.workdir / consts.LOCALES_CACHE_DIR, + tenant_uuid=TENANT_UUID, + ) + + +def test_load_locale_data(): + locale = TemplateLocale.load({ + 'uuid': LOCALE_UUID, + 'name': 'Czech', + 'code': 'cs', + 'updatedAt': '2026-01-01T00:00:00Z', + }) + assert locale is not None + assert locale.uuid == LOCALE_UUID + assert locale.code == 'cs' + + +def test_load_locale_none(): + assert TemplateLocale.load(None) is None + + +def test_load_locale_rejects_non_uuid(): + assert TemplateLocale.load({'uuid': 'not-a-uuid', 'name': 'X', 'code': 'cs'}) is None + + +def test_load_locale_rejects_missing_uuid(): + assert TemplateLocale.load({'name': 'X', 'code': 'cs'}) is None + + +def test_po_is_compiled_and_cached_in_s3(fake_context, loader): + fake_context.s3.objects[f'{LOCALE_UUID}/{consts.LOCALE_PO_FILE_NAME}'] = make_po_bytes( + {'Hello': 'Ahoj'}, + ) + translations = loader.load(make_locale()) + assert isinstance(translations, gettext.GNUTranslations) + assert translations.gettext('Hello') == 'Ahoj' + assert f'{LOCALE_UUID}/{consts.LOCALE_MO_FILE_NAME}' in fake_context.s3.stored + + +def test_compiled_mo_from_s3_is_preferred(fake_context, loader): + po = polib.pofile(str(_write_po(fake_context, {'Hello': 'Ahoj'}))) + mo_path = fake_context.workdir / 'source.mo' + po.save_as_mofile(str(mo_path)) + fake_context.s3.objects = { + f'{LOCALE_UUID}/{consts.LOCALE_MO_FILE_NAME}': mo_path.read_bytes(), + } + translations = loader.load(make_locale()) + assert translations.gettext('Hello') == 'Ahoj' + assert fake_context.s3.stored == [] + + +def test_missing_locale_raises(loader): + with pytest.raises(RuntimeError): + loader.load(make_locale()) + + +def test_cache_hit_avoids_s3(fake_context, loader): + fake_context.s3.objects[f'{LOCALE_UUID}/{consts.LOCALE_PO_FILE_NAME}'] = make_po_bytes( + {'Hello': 'Ahoj'}, + ) + loader.load(make_locale()) + fake_context.s3.downloads.clear() + translations = loader.load(make_locale()) + assert translations.gettext('Hello') == 'Ahoj' + assert fake_context.s3.downloads == [] + + +def test_changed_updated_at_invalidates_cache(fake_context, loader): + fake_context.s3.objects[f'{LOCALE_UUID}/{consts.LOCALE_PO_FILE_NAME}'] = make_po_bytes( + {'Hello': 'Ahoj'}, + ) + loader.load(make_locale()) + fake_context.s3.objects = { + f'{LOCALE_UUID}/{consts.LOCALE_PO_FILE_NAME}': make_po_bytes({'Hello': 'Nazdar'}), + } + fake_context.s3.downloads.clear() + translations = loader.load(make_locale(updated_at='2026-02-02T00:00:00Z')) + assert translations.gettext('Hello') == 'Nazdar' + assert fake_context.s3.downloads != [] + + +def test_mo_without_stamp_is_a_miss(fake_context, loader): + fake_context.s3.objects[f'{LOCALE_UUID}/{consts.LOCALE_PO_FILE_NAME}'] = make_po_bytes( + {'Hello': 'Ahoj'}, + ) + loader.load(make_locale()) + locale_dir = fake_context.workdir / consts.LOCALES_CACHE_DIR / LOCALE_UUID + (locale_dir / consts.LOCALE_STAMP_FILE_NAME).unlink() + (locale_dir / consts.LOCALE_MO_FILE_NAME).write_bytes(b'truncated') + fake_context.s3.downloads.clear() + + translations = loader.load(make_locale()) + assert translations.gettext('Hello') == 'Ahoj' + assert fake_context.s3.downloads != [] + + +def _write_po(fake_context, translations): + po_path = fake_context.workdir / 'source.po' + po_path.write_bytes(make_po_bytes(translations)) + return po_path diff --git a/packages/dsw-document-worker/tests/test_pot.py b/packages/dsw-document-worker/tests/test_pot.py new file mode 100644 index 00000000..cae1c389 --- /dev/null +++ b/packages/dsw-document-worker/tests/test_pot.py @@ -0,0 +1,161 @@ +import dataclasses +import types + +import pytest + +from dsw.command_queue import CommandJobError +from dsw.document_worker.pot import ( + PotFileRequest, + extract_catalog, + render_pot_file, +) + + +@dataclasses.dataclass +class FakeTemplateFile: + file_name: str + content: str + + +def make_command(**body): + return types.SimpleNamespace( + uuid='11111111-1111-1111-1111-111111111111', + tenant_uuid='22222222-2222-2222-2222-222222222222', + function='generatePotFile', + body=body, + ) + + +def make_pot(*files, language='en'): + result = extract_catalog( + [FakeTemplateFile(name, content) for name, content in files], + project='org:tid:1.0.0', + version='1.0.0', + language=language, + ) + return result, render_pot_file(result).decode('utf-8') + + +def test_extract_trans_block(): + _, pot = make_pot(('src/a.j2', '{% trans %}Hello{% endtrans %}')) + assert 'msgid "Hello"' in pot + assert '#: src/a.j2:1' in pot + + +def test_extract_plural(): + _, pot = make_pot(( + 'src/a.j2', + '{% trans count %}{{ count }} item{% pluralize %}{{ count }} items{% endtrans %}', + )) + assert 'msgid "%(count)s item"' in pot + assert 'msgid_plural "%(count)s items"' in pot + assert 'msgstr[0] ""' in pot + assert 'msgstr[1] ""' in pot + + +def test_extract_underscore_and_pgettext(): + _, pot = make_pot(('src/a.j2', "{{ _('World') }}\n{{ pgettext('menu', 'Open') }}")) + assert 'msgid "World"' in pot + assert 'msgctxt "menu"' in pot + assert 'msgid "Open"' in pot + + +def test_extract_translators_comment(): + _, pot = make_pot(( + 'src/a.j2', + '{# TRANSLATORS: shown on top #}\n{% trans %}Hello{% endtrans %}', + )) + assert '#. shown on top' in pot + + +def test_extract_survives_do_extension(): + _, pot = make_pot(('src/a.j2', "{% do [] %}{{ _('Alpha') }}")) + assert 'msgid "Alpha"' in pot + + +def test_extract_survives_loopcontrols_extension(): + _, pot = make_pot(( + 'src/a.j2', + "{% for i in [1] %}{% break %}{% endfor %}{{ _('Alpha') }}", + )) + assert 'msgid "Alpha"' in pot + + +def test_broken_file_is_isolated(): + result, pot = make_pot( + ('src/broken.j2', '{% if %}'), + ('src/ok.j2', "{{ _('Alpha') }}"), + ) + assert result.failed_files == ['src/broken.j2'] + assert 'msgid "Alpha"' in pot + assert 'Skipped files that could not be parsed: src/broken.j2' in pot + + +def test_header_fields(): + _, pot = make_pot(('src/a.j2', "{{ _('Alpha') }}"), language='cs') + assert 'Project-Id-Version: org:tid:1.0.0 1.0.0' in pot + assert 'Language: cs' in pot + assert 'Plural-Forms: nplurals=' in pot + assert 'charset=utf-8' in pot + assert '#, fuzzy' not in pot + + +def test_header_without_known_language(): + _, pot = make_pot(('src/a.j2', "{{ _('Alpha') }}"), language='not a language') + assert 'msgid "Alpha"' in pot + assert 'Language:' not in pot + assert 'Plural-Forms:' not in pot + + +def test_messages_are_sorted(): + _, pot = make_pot(('src/a.j2', "{{ _('Beta') }}{{ _('Alpha') }}")) + assert pot.index('msgid "Alpha"') < pot.index('msgid "Beta"') + + +def test_request_load_ok(): + rq = PotFileRequest.load(make_command( + documentTemplateUuid='33333333-3333-3333-3333-333333333333', + organizationId='org', + templateId='tid', + version='1.0.0', + language='cs', + )) + assert rq.coordinates == 'org:tid:1.0.0' + assert rq.file_name == 'org_tid_1.0.0.pot' + assert rq.language == 'cs' + + +def test_request_load_without_language(): + rq = PotFileRequest.load(make_command( + documentTemplateUuid='33333333-3333-3333-3333-333333333333', + organizationId='org', + templateId='tid', + version='1.0.0', + language=None, + )) + assert rq.language == 'en' + + +def test_request_load_rejects_bad_uuid(): + with pytest.raises(CommandJobError) as e: + PotFileRequest.load(make_command( + documentTemplateUuid='not-a-uuid', + organizationId='org', + templateId='tid', + version='1.0.0', + )) + assert not e.value.try_again + + +@pytest.mark.parametrize('field', ['organizationId', 'templateId', 'version']) +def test_request_load_rejects_traversal(field): + body = { + 'documentTemplateUuid': '33333333-3333-3333-3333-333333333333', + 'organizationId': 'org', + 'templateId': 'tid', + 'version': '1.0.0', + } + body[field] = '../../etc/passwd' + with pytest.raises(CommandJobError) as e: + PotFileRequest.load(make_command(**body)) + assert not e.value.try_again diff --git a/packages/dsw-document-worker/tests/test_steps_i18n.py b/packages/dsw-document-worker/tests/test_steps_i18n.py new file mode 100644 index 00000000..cfa43cd0 --- /dev/null +++ b/packages/dsw-document-worker/tests/test_steps_i18n.py @@ -0,0 +1,88 @@ +import gettext +import pathlib +import types + +import polib +import pytest + +from dsw.document_worker.templates.locales import RenderContext +from dsw.document_worker.templates.steps.template import Jinja2Step + + +ROOT_FILE = 'src/root.j2' +ROOT_CONTENT = "{% trans %}Hello{% endtrans %}|{{ _('World') }}" + + +@pytest.fixture +def template_dir(tmp_path: pathlib.Path) -> pathlib.Path: + root = tmp_path / ROOT_FILE + root.parent.mkdir(parents=True, exist_ok=True) + root.write_text(ROOT_CONTENT, encoding='utf-8') + return tmp_path + + +@pytest.fixture +def step(fake_context, template_dir: pathlib.Path) -> Jinja2Step: + template = types.SimpleNamespace( + template_dir=template_dir, + coordinates='org:tid:1.0.0', + ) + return Jinja2Step(template, {'template': ROOT_FILE}) + + +def make_translations(tmp_path: pathlib.Path, + translations: dict[str, str]) -> gettext.GNUTranslations: + po = polib.POFile() + po.metadata = {'Content-Type': 'text/plain; charset=utf-8', 'Language': 'cs'} + for msgid, msgstr in translations.items(): + po.append(polib.POEntry(msgid=msgid, msgstr=msgstr)) + mo_path = tmp_path / 'messages.mo' + po.save_as_mofile(str(mo_path)) + with mo_path.open('rb') as fp: + return gettext.GNUTranslations(fp) + + +def render(step: Jinja2Step) -> str: + return step.execute_first({}).content.decode('utf-8') + + +def test_renders_untranslated_by_default(step): + assert render(step) == 'Hello|World' + + +def test_renders_translated_after_before_render(step, tmp_path): + translations = make_translations(tmp_path, {'Hello': 'Ahoj', 'World': 'Svete'}) + step.before_render(RenderContext(translations=translations, language='cs')) + assert render(step) == 'Ahoj|Svete' + + +def test_locale_does_not_leak_to_next_document(step, tmp_path): + translations = make_translations(tmp_path, {'Hello': 'Ahoj', 'World': 'Svete'}) + step.before_render(RenderContext(translations=translations, language='cs')) + assert render(step) == 'Ahoj|Svete' + + step.before_render(RenderContext.null()) + assert render(step) == 'Hello|World' + + +def test_translation_helpers_on_step(step, tmp_path): + translations = make_translations(tmp_path, {'Hello': 'Ahoj'}) + step.before_render(RenderContext(translations=translations, language='cs')) + assert step.language == 'cs' + assert step.gettext('Hello') == 'Ahoj' + assert step.translations is translations + + +def test_legacy_i18n_options_are_ignored(fake_context, template_dir): + template = types.SimpleNamespace( + template_dir=template_dir, + coordinates='org:tid:1.0.0', + ) + step = Jinja2Step(template, { + 'template': ROOT_FILE, + 'jinja-ext': 'i18n', + 'i18n-dir': 'locale', + 'i18n-lang': 'cs', + 'i18n-domain': 'default', + }) + assert render(step) == 'Hello|World' diff --git a/packages/dsw-document-worker/tests/test_steps_policies.py b/packages/dsw-document-worker/tests/test_steps_policies.py new file mode 100644 index 00000000..60ff0001 --- /dev/null +++ b/packages/dsw-document-worker/tests/test_steps_policies.py @@ -0,0 +1,43 @@ +import pathlib +import types + +import pytest + +from dsw.document_worker.templates.steps.template import Jinja2Step + + +ROOT_FILE = 'src/root.j2' + + +@pytest.fixture +def template_dir(tmp_path: pathlib.Path) -> pathlib.Path: + root = tmp_path / ROOT_FILE + root.parent.mkdir(parents=True, exist_ok=True) + root.write_text('{{ ctx }}', encoding='utf-8') + return tmp_path + + +def make_step(template_dir: pathlib.Path, **options) -> Jinja2Step: + template = types.SimpleNamespace( + template_dir=template_dir, + coordinates='org:tid:1.0.0', + ) + return Jinja2Step(template, {'template': ROOT_FILE, **options}) + + +def test_extra_schemes_applied(fake_context, template_dir): + step = make_step(template_dir, **{'policy.urlize.extra_schemes': 'ftp:,tel:'}) + assert step.j2_env.policies['urlize.extra_schemes'] == ['ftp:', 'tel:'] + + +def test_extra_schemes_does_not_clobber_truncate_leeway(fake_context, template_dir): + step = make_step(template_dir, **{ + 'policy.urlize.extra_schemes': 'ftp:', + 'policy.truncate.leeway': '7', + }) + assert step.j2_env.policies['truncate.leeway'] == '7' + + +def test_truncate_leeway_default_kept_without_extra_schemes(fake_context, template_dir): + step = make_step(template_dir, **{'policy.urlize.extra_schemes': 'ftp:'}) + assert step.j2_env.policies['truncate.leeway'] == 5 diff --git a/packages/dsw-models/CHANGELOG.md b/packages/dsw-models/CHANGELOG.md index 5c3c2b21..15a753ea 100644 --- a/packages/dsw-models/CHANGELOG.md +++ b/packages/dsw-models/CHANGELOG.md @@ -7,6 +7,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- `language` field of `DocumentTemplateMetadata` + ## [4.34.0] diff --git a/packages/dsw-models/dsw/models/document_template/metadata.py b/packages/dsw-models/dsw/models/document_template/metadata.py index 10b5d01d..2c8f54d5 100644 --- a/packages/dsw-models/dsw/models/document_template/metadata.py +++ b/packages/dsw-models/dsw/models/document_template/metadata.py @@ -44,6 +44,7 @@ class DocumentTemplateMetadata(BaseModel): name: str description: str metamodel_version: str + language: str = 'en' license: str readme: str allowed_packages: list[PackagePattern] diff --git a/packages/dsw-storage/CHANGELOG.md b/packages/dsw-storage/CHANGELOG.md index cad454f8..84246bc5 100644 --- a/packages/dsw-storage/CHANGELOG.md +++ b/packages/dsw-storage/CHANGELOG.md @@ -7,6 +7,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Methods for downloading and storing document template locales and for storing the document template POT file + ## [4.34.0] diff --git a/packages/dsw-storage/dsw/storage/s3storage.py b/packages/dsw-storage/dsw/storage/s3storage.py index 59550c37..33ad7c41 100644 --- a/packages/dsw-storage/dsw/storage/s3storage.py +++ b/packages/dsw-storage/dsw/storage/s3storage.py @@ -18,6 +18,10 @@ LOG = logging.getLogger(__name__) DOCUMENTS_DIR = 'documents' +DOCUMENT_TEMPLATES_DIR = 'document-templates' +DOCUMENT_TEMPLATE_LOCALES_DIR = 'document-template-locales' + +POT_CONTENT_TYPE = 'text/x-gettext-translation' RETRY_S3_MULTIPLIER = 0.5 RETRY_S3_TRIES = 3 @@ -122,7 +126,7 @@ def download_template_asset(self, *, tenant_uuid: str, template_uuid: str, file_name: str, target_path: Path) -> bool: return self._download_file( tenant_uuid=tenant_uuid, - file_name=f'document-templates/{template_uuid}/{file_name}', + file_name=f'{DOCUMENT_TEMPLATES_DIR}/{template_uuid}/{file_name}', target_path=target_path, ) @@ -155,6 +159,59 @@ def download_locale(self, *, tenant_uuid: str, locale_uuid: str, target_path=target_path, ) + @tenacity.retry( + reraise=True, + wait=tenacity.wait_exponential(multiplier=RETRY_S3_MULTIPLIER), + stop=tenacity.stop_after_attempt(RETRY_S3_TRIES), + before=tenacity.before_log(LOG, logging.DEBUG), + after=tenacity.after_log(LOG, logging.DEBUG), + ) + def download_document_template_locale(self, *, tenant_uuid: str, locale_uuid: str, + file_name: str, target_path: Path) -> bool: + return self._download_file( + tenant_uuid=tenant_uuid, + file_name=f'{DOCUMENT_TEMPLATE_LOCALES_DIR}/{locale_uuid}/{file_name}', + target_path=target_path, + ) + + @tenacity.retry( + reraise=True, + wait=tenacity.wait_exponential(multiplier=RETRY_S3_MULTIPLIER), + stop=tenacity.stop_after_attempt(RETRY_S3_TRIES), + before=tenacity.before_log(LOG, logging.DEBUG), + after=tenacity.after_log(LOG, logging.DEBUG), + ) + def store_document_template_locale(self, *, tenant_uuid: str, locale_uuid: str, + file_name: str, content_type: str, data: bytes): + object_name = f'{DOCUMENT_TEMPLATE_LOCALES_DIR}/{locale_uuid}/{file_name}' + if self.multi_tenant: + object_name = f'{tenant_uuid}/{object_name}' + self._put_object( + object_name=object_name, + content_type=content_type, + data=data, + metadata=None, + ) + + @tenacity.retry( + reraise=True, + wait=tenacity.wait_exponential(multiplier=RETRY_S3_MULTIPLIER), + stop=tenacity.stop_after_attempt(RETRY_S3_TRIES), + before=tenacity.before_log(LOG, logging.DEBUG), + after=tenacity.after_log(LOG, logging.DEBUG), + ) + def store_document_template_pot(self, *, tenant_uuid: str, template_uuid: str, + file_name: str, data: bytes): + object_name = f'{DOCUMENT_TEMPLATES_DIR}/{template_uuid}/{file_name}' + if self.multi_tenant: + object_name = f'{tenant_uuid}/{object_name}' + self._put_object( + object_name=object_name, + content_type=POT_CONTENT_TYPE, + data=data, + metadata=None, + ) + @tenacity.retry( reraise=True, wait=tenacity.wait_exponential(multiplier=RETRY_S3_MULTIPLIER), diff --git a/packages/dsw-tdk/CHANGELOG.md b/packages/dsw-tdk/CHANGELOG.md index dcb9643b..711b1dc8 100644 --- a/packages/dsw-tdk/CHANGELOG.md +++ b/packages/dsw-tdk/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New `pot` command creating a POT file with translatable strings of the template project +- `language` field in `template.json` (prompted by `dsw-tdk new`, defaults to `en`) + +### Changed + +- Update to DT metamodel 18.3 + ## [4.34.0] diff --git a/packages/dsw-tdk/README.md b/packages/dsw-tdk/README.md index e9963db2..77d401b0 100644 --- a/packages/dsw-tdk/README.md +++ b/packages/dsw-tdk/README.md @@ -59,6 +59,7 @@ For further information, visit our [documentation](https://docs.ds-wizard.org). - `put` = upload a template to DSW instance (create or update) - `verify` = check the metadata of local template project - `package` = create a distribution ZIP package that is importable to DSW via web interface +- `pot` = create a POT file with translatable strings of the local template project ### Environment variables diff --git a/packages/dsw-tdk/dsw/tdk/cli.py b/packages/dsw-tdk/dsw/tdk/cli.py index 6830777a..dd5aa5e9 100644 --- a/packages/dsw-tdk/dsw/tdk/cli.py +++ b/packages/dsw-tdk/dsw/tdk/cli.py @@ -243,6 +243,8 @@ def interact_builder(builder: TemplateBuilder): default='My custom template') prompt_fill('License', obj=builder, attr='license', default='CC0') + prompt_fill('Language', obj=builder, attr='language', + default=consts.DEFAULT_LANGUAGE) click.echo('=' * 60) formats = interact_formats() for format_spec in formats.values(): @@ -565,6 +567,27 @@ def verify_template(ctx, template_dir): click.echo(f' - {err.field_name}: {err.message}') +@main.command(help='Create POT file with translatable strings of a template.', name='pot') +@click.argument('TEMPLATE-DIR', type=DIR_TYPE, default=CURRENT_DIR, required=False) +@click.option('-o', '--output', default=consts.POT_FILE_DEFAULT, type=click.Path(writable=True), + show_default=True, help='Target POT file.') +@click.option('-f', '--force', is_flag=True, help='Overwrite POT file if already exists.') +@click.pass_context +def create_pot_file(ctx, template_dir, output, force: bool): + tdk = TDKCore(logger=ctx.obj.logger) + load_local(tdk, template_dir) + try: + pot_file = tdk.create_pot_file(output=pathlib.Path(output), force=force) + except Exception as e: + ClickPrinter.failure('Failed to create the POT file') + ClickPrinter.error(f'> {e}') + sys.exit(1) + for failed_file in pot_file.failed_files: + ClickPrinter.warning(f'Skipped file {failed_file} that could not be parsed') + filename = click.style(output, bold=True) + ClickPrinter.success(f'POT file {filename} created') + + @main.group(help='Manage shared user configuration (~/.dsw-tdk).', name='config') @click.pass_context def config(ctx): diff --git a/packages/dsw-tdk/dsw/tdk/consts.py b/packages/dsw-tdk/dsw/tdk/consts.py index f1512c85..fd530cd2 100644 --- a/packages/dsw-tdk/dsw/tdk/consts.py +++ b/packages/dsw-tdk/dsw/tdk/consts.py @@ -11,7 +11,7 @@ PACKAGE_NAME = 'dsw-tdk' METAMODEL_VERSION_MAJOR = 18 -METAMODEL_VERSION_MINOR = 2 +METAMODEL_VERSION_MINOR = 3 METAMODEL_VERSION = f'{METAMODEL_VERSION_MAJOR}.{METAMODEL_VERSION_MINOR}' try: @@ -29,7 +29,13 @@ DEFAULT_LIST_FORMAT = '{template.id:<50} {template.name:<30} [{template.uuid}]' DEFAULT_ENCODING = 'utf-8' +DEFAULT_LANGUAGE = 'en' +DEFAULT_LOCALE_DOMAIN = 'default' DEFAULT_README = pathlib.Path('README.md') +JINJA_EXTENSIONS = ('jinja2.ext.do', 'jinja2.ext.loopcontrols') +JINJA_I18N_TRIMMED = True +POT_FILE_DEFAULT = 'template.pot' + TEMPLATE_FILE = 'template.json' PathspecFactory = pathspec.patterns.GitWildMatchPattern diff --git a/packages/dsw-tdk/dsw/tdk/core.py b/packages/dsw-tdk/dsw/tdk/core.py index 4e272e07..502405b4 100644 --- a/packages/dsw-tdk/dsw/tdk/core.py +++ b/packages/dsw-tdk/dsw/tdk/core.py @@ -15,6 +15,7 @@ from . import consts from .api_client import WizardAPIClient, WizardCommunicationError from .model import Template, TemplateFile, TemplateFileType, TemplateProject +from .pot import PotFile, create_pot_file from .utils import UUIDGen from .validation import TemplateValidator, ValidationError @@ -348,6 +349,18 @@ async def store_remote_files(self): file.remote_type = TemplateFileType.FILE if file.is_text else TemplateFileType.ASSET await self._create_template_file(file=file, project_update=True) + def create_pot_file(self, output: pathlib.Path, force: bool) -> PotFile: + if output.exists() and not force: + raise RuntimeError(f'File {output} already exists (not forced)') + template = self.safe_project.safe_template + self.logger.info('Extracting messages from template files of %s', template.coordinates) + pot_file = create_pot_file(template) + for filename in pot_file.failed_files: + self.logger.warning('Skipped file %s that could not be parsed', filename) + self.logger.debug('Writing POT file: %s', output.as_posix()) + output.write_bytes(pot_file.data) + return pot_file + def create_package(self, output: pathlib.Path, force: bool): if output.exists() and not force: raise RuntimeError(f'File {output} already exists (not forced)') diff --git a/packages/dsw-tdk/dsw/tdk/model.py b/packages/dsw-tdk/dsw/tdk/model.py index f2408b92..306a6a8d 100644 --- a/packages/dsw-tdk/dsw/tdk/model.py +++ b/packages/dsw-tdk/dsw/tdk/model.py @@ -178,7 +178,7 @@ class Template: def __init__(self, *, uuid=None, template_id=None, organization_id=None, version=None, name=None, description=None, readme=None, template_license=None, - metamodel_version=None, tdk_config=None, loaded_json=None): + metamodel_version=None, language=None, tdk_config=None, loaded_json=None): self.uuid: str | None = uuid self.template_id: str | None = template_id self.organization_id: str | None = organization_id @@ -188,6 +188,7 @@ def __init__(self, *, uuid=None, template_id=None, organization_id=None, self.readme: str | None = readme self.license: str | None = template_license self.metamodel_version: str = metamodel_version or consts.METAMODEL_VERSION + self.language: str = consts.DEFAULT_LANGUAGE if language is None else language self.allowed_packages: list[PackageFilter] = [] self.formats: list[Format] = [] self.files: dict[str, TemplateFile] = {} @@ -228,6 +229,7 @@ def _common_load(cls, data): description=data.get('description', ''), template_license=data.get('license', 'no-license'), metamodel_version=data.get('metamodelVersion', consts.METAMODEL_VERSION), + language=data.get('language', consts.DEFAULT_LANGUAGE), readme=data.get('readme', ''), ) for ap_data in data.get('allowedPackages', []): @@ -256,6 +258,7 @@ def serialize_local(self) -> collections.OrderedDict: self.loaded_json['description'] = self.description self.loaded_json['license'] = self.license self.loaded_json['metamodelVersion'] = self.metamodel_version + self.loaded_json['language'] = self.language self.loaded_json['allowedPackages'] = [ap.serialize() for ap in self.allowed_packages] self.loaded_json['formats'] = [f.serialize() for f in self.formats] self.loaded_json['_tdk'] = self.tdk_config.serialize() @@ -271,6 +274,7 @@ def serialize_remote(self) -> dict[str, typing.Any]: 'description': self.description, 'license': self.license, 'metamodelVersion': self.metamodel_version, + 'language': self.language, 'readme': self.readme, 'allowedPackages': [ap.serialize() for ap in self.allowed_packages], 'formats': [f.serialize() for f in self.formats], @@ -287,6 +291,7 @@ def serialize_for_package(self) -> dict[str, typing.Any]: 'description': self.description, 'license': self.license, 'metamodelVersion': self.metamodel_version, + 'language': self.language, 'readme': self.readme, 'allowedPackages': [ap.serialize() for ap in self.allowed_packages], 'formats': [f.serialize() for f in self.formats], @@ -300,6 +305,7 @@ def serialize_for_update(self) -> dict[str, typing.Any]: 'description': self.description, 'license': self.license, 'metamodelVersion': self.metamodel_version, + 'language': self.language, 'readme': self.readme, 'allowedPackages': [ap.serialize() for ap in self.allowed_packages], 'formats': [f.serialize() for f in self.formats], @@ -323,6 +329,7 @@ def serialize_local_new(self) -> dict[str, typing.Any]: 'description': self.description, 'license': self.license, 'metamodelVersion': self.metamodel_version, + 'language': self.language, 'allowedPackages': [ap.serialize() for ap in self.allowed_packages], 'formats': [f.serialize() for f in self.formats], '_tdk': self.tdk_config.serialize(), diff --git a/packages/dsw-tdk/dsw/tdk/pot.py b/packages/dsw-tdk/dsw/tdk/pot.py new file mode 100644 index 00000000..705c2265 --- /dev/null +++ b/packages/dsw-tdk/dsw/tdk/pot.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import io +import typing + +import babel +import jinja2.exceptions +import jinja2.ext +from babel.messages.catalog import Catalog +from babel.messages.extract import DEFAULT_KEYWORDS, extract +from babel.messages.pofile import write_po + +from . import consts +from .model import TemplateFile + + +if typing.TYPE_CHECKING: + from .model import Template + + +# Must stay in sync with dsw-document-worker (dsw/document_worker/pot.py), so +# that a POT file created locally matches the one generated by the server. +COMMENT_TAGS = ('TRANSLATORS:',) +EXTRACT_METHOD = typing.cast('typing.Any', jinja2.ext.babel_extract) +NO_WRAP = 0 +EXTRACT_OPTIONS = { + 'encoding': consts.DEFAULT_ENCODING, + 'extensions': ','.join(consts.JINJA_EXTENSIONS), + 'silent': 'false', + 'newstyle_gettext': 'true', + 'trimmed': str(consts.JINJA_I18N_TRIMMED).lower(), +} + + +class PotFile(typing.NamedTuple): + data: bytes + failed_files: list[str] + + +def _template_files(template: Template) -> list[TemplateFile]: + return sorted( + (f for f in template.files.values() + if f.filename.name.endswith(TemplateFile.TEMPLATE_EXTENSIONS)), + key=lambda f: f.filename.as_posix(), + ) + + +def _make_catalog(template: Template) -> Catalog: + locale: babel.Locale | None = None + try: + locale = babel.Locale.parse(template.language.replace('-', '_')) + except (ValueError, babel.UnknownLocaleError): + locale = None + return Catalog( + locale=locale, + domain=consts.DEFAULT_LOCALE_DOMAIN, + project=template.coordinates, + version=template.version, + charset=consts.DEFAULT_ENCODING, + fuzzy=False, + ) + + +def create_pot_file(template: Template) -> PotFile: + catalog = _make_catalog(template) + failed_files = [] + for file in _template_files(template): + filename = file.filename.as_posix() + try: + messages = list(extract( + method=EXTRACT_METHOD, + fileobj=io.BytesIO(file.content), + keywords=DEFAULT_KEYWORDS, + comment_tags=COMMENT_TAGS, + options=EXTRACT_OPTIONS, + )) + except jinja2.exceptions.TemplateSyntaxError: + failed_files.append(filename) + continue + for lineno, message, comments, context in messages: + catalog.add(message, None, [(filename, lineno)], + auto_comments=comments, context=context) + lines = [f'# Translations template for {template.coordinates}.'] + if failed_files: + lines.append(f'# Skipped files that could not be parsed: {", ".join(failed_files)}') + catalog.header_comment = '\n'.join(lines) + '\n' + buffer = io.BytesIO() + write_po(buffer, catalog, width=NO_WRAP, omit_header=False, sort_output=True) + return PotFile(data=buffer.getvalue(), failed_files=failed_files) diff --git a/packages/dsw-tdk/dsw/tdk/utils.py b/packages/dsw-tdk/dsw/tdk/utils.py index 8ba903ad..985c4274 100644 --- a/packages/dsw-tdk/dsw/tdk/utils.py +++ b/packages/dsw-tdk/dsw/tdk/utils.py @@ -159,6 +159,15 @@ def license(self, value: str): self.template.license = value self._validate_field('license') + @property + def language(self): + return self.template.language + + @language.setter + def language(self, value: str): + self.template.language = value + self._validate_field('language') + def build(self) -> Template: readme = j2_env.get_template('README.md.j2').render(template=self.template) self.template.readme = readme diff --git a/packages/dsw-tdk/dsw/tdk/validation.py b/packages/dsw-tdk/dsw/tdk/validation.py index f4b42481..d498551b 100644 --- a/packages/dsw-tdk/dsw/tdk/validation.py +++ b/packages/dsw-tdk/dsw/tdk/validation.py @@ -291,6 +291,7 @@ def _validate_formats(field_name: str, value: list[Format]) -> list[ValidationEr 'readme': [_validate_required, _validate_non_empty], 'license': [_validate_required, _validate_non_empty], 'metamodel_version': [_validate_metamodel_version, _validate_required], + 'language': [_validate_required, _validate_non_empty], 'allowed_packages': [_validate_package_filters], 'formats': [_validate_required, _validate_formats], }) diff --git a/packages/dsw-tdk/pyproject.toml b/packages/dsw-tdk/pyproject.toml index e9581186..b201461a 100644 --- a/packages/dsw-tdk/pyproject.toml +++ b/packages/dsw-tdk/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ requires-python = ">=3.12, <4" dependencies = [ "aiohttp", + "Babel", "click", "colorama", "humanize", diff --git a/packages/dsw-tdk/tests/fixtures/test_example01/src/template.json.j2 b/packages/dsw-tdk/tests/fixtures/test_example01/src/template.json.j2 index f30ada67..1a7f4379 100644 --- a/packages/dsw-tdk/tests/fixtures/test_example01/src/template.json.j2 +++ b/packages/dsw-tdk/tests/fixtures/test_example01/src/template.json.j2 @@ -5,6 +5,8 @@ Example -

This is example

+ {# TRANSLATORS: main heading of the example #} +

{% trans %}This is example{% endtrans %}

+

{{ _('Hello') }}

diff --git a/packages/dsw-tdk/tests/fixtures/test_example01/template.json b/packages/dsw-tdk/tests/fixtures/test_example01/template.json index d2e19136..3e58e9cf 100644 --- a/packages/dsw-tdk/tests/fixtures/test_example01/template.json +++ b/packages/dsw-tdk/tests/fixtures/test_example01/template.json @@ -5,6 +5,7 @@ "name": "Test template 01", "description": "Dummy document template 01 for testing purposes", "metamodelVersion": "17.1", + "language": "en", "license": "Apache-2.0", "allowedPackages": [ { diff --git a/packages/dsw-tdk/tests/test_cmd_new.py b/packages/dsw-tdk/tests/test_cmd_new.py index 4603a501..99f47a30 100644 --- a/packages/dsw-tdk/tests/test_cmd_new.py +++ b/packages/dsw-tdk/tests/test_cmd_new.py @@ -1,3 +1,4 @@ +import json import pathlib import click.testing @@ -7,7 +8,7 @@ def test_new_no_dir(tmp_path: pathlib.Path): runner = click.testing.CliRunner() - inputs = ['Test template', 'dsw', 'test-template', '0.1.0', 'some description', 'CC0', + inputs = ['Test template', 'dsw', 'test-template', '0.1.0', 'some description', 'CC0', 'en', 'y', 'HTML', 'html', 'text/html', 'src/template.html.j2', 'n'] with runner.isolated_filesystem(temp_dir=tmp_path) as isolated_dir: result = runner.invoke(main, args=['new'], input='\n'.join(inputs)) @@ -24,7 +25,7 @@ def test_new_no_dir(tmp_path: pathlib.Path): def test_new_dir(tmp_path: pathlib.Path): runner = click.testing.CliRunner() - inputs = ['Test template', 'dsw', 'test-template', '0.1.0', 'some description', 'CC0', + inputs = ['Test template', 'dsw', 'test-template', '0.1.0', 'some description', 'CC0', 'cs', 'y', 'HTML', 'html', 'text/html', 'src/template.html.j2', 'n'] with runner.isolated_filesystem(temp_dir=tmp_path) as isolated_dir: result = runner.invoke(main, args=['new', 'my-template'], input='\n'.join(inputs)) @@ -36,10 +37,15 @@ def test_new_dir(tmp_path: pathlib.Path): assert 'my-template/src' in paths assert 'my-template/src/template.html.j2' in paths + descriptor = json.loads( + (pathlib.Path(isolated_dir) / 'my-template' / 'template.json').read_text('utf-8'), + ) + assert descriptor['language'] == 'cs' + def test_new_without_force(tmp_path: pathlib.Path): runner = click.testing.CliRunner() - inputs = ['Test template', 'dsw', 'test-template', '0.1.0', 'some description', 'CC0', + inputs = ['Test template', 'dsw', 'test-template', '0.1.0', 'some description', 'CC0', 'en', 'y', 'HTML', 'html', 'text/html', 'src/template.html.j2', 'n'] with runner.isolated_filesystem(temp_dir=tmp_path) as isolated_dir: root_dir = pathlib.Path(isolated_dir) @@ -59,7 +65,7 @@ def test_new_without_force(tmp_path: pathlib.Path): def test_new_with_force(tmp_path: pathlib.Path): runner = click.testing.CliRunner() - inputs = ['Test template', 'dsw', 'test-template', '0.1.0', 'some description', 'CC0', + inputs = ['Test template', 'dsw', 'test-template', '0.1.0', 'some description', 'CC0', 'en', 'y', 'HTML', 'html', 'text/html', 'src/template.html.j2', 'n'] with runner.isolated_filesystem(temp_dir=tmp_path) as isolated_dir: root_dir = pathlib.Path(isolated_dir) diff --git a/packages/dsw-tdk/tests/test_cmd_pot.py b/packages/dsw-tdk/tests/test_cmd_pot.py new file mode 100644 index 00000000..d2fb25e7 --- /dev/null +++ b/packages/dsw-tdk/tests/test_cmd_pot.py @@ -0,0 +1,66 @@ +import pathlib +import shutil + +import click.testing + +from dsw.tdk import main + + +def test_pot_ok(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + runner = click.testing.CliRunner() + template_path = fixtures_path / 'test_example01' + output = tmp_path / 'template.pot' + result = runner.invoke(main, args=['pot', template_path.as_posix(), + '-o', output.as_posix()]) + assert result.exit_code == 0 + assert 'created' in result.output + + content = output.read_text(encoding='utf-8') + assert 'Project-Id-Version: test:example01:1.0.0 1.0.0' in content + assert 'Language: en' in content + assert 'Plural-Forms: nplurals=2; plural=(n != 1);' in content + assert 'charset=utf-8' in content + assert '#. main heading of the example' in content + assert '#: src/template.json.j2:9' in content + assert 'msgid "This is example"' in content + assert 'msgid "Hello"' in content + + +def test_pot_existing_without_force(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + runner = click.testing.CliRunner() + template_path = fixtures_path / 'test_example01' + output = tmp_path / 'template.pot' + output.write_text('original', encoding='utf-8') + result = runner.invoke(main, args=['pot', template_path.as_posix(), + '-o', output.as_posix()]) + assert result.exit_code == 1 + assert 'Failed to create the POT file' in result.output + assert output.read_text(encoding='utf-8') == 'original' + + +def test_pot_existing_with_force(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + runner = click.testing.CliRunner() + template_path = fixtures_path / 'test_example01' + output = tmp_path / 'template.pot' + output.write_text('original', encoding='utf-8') + result = runner.invoke(main, args=['pot', template_path.as_posix(), + '-o', output.as_posix(), '--force']) + assert result.exit_code == 0 + assert 'msgid "This is example"' in output.read_text(encoding='utf-8') + + +def test_pot_skips_unparseable_file(fixtures_path: pathlib.Path, tmp_path: pathlib.Path): + runner = click.testing.CliRunner() + template_path = tmp_path / 'project' + shutil.copytree(fixtures_path / 'test_example01', template_path) + (template_path / 'src' / 'broken.j2').write_text('{% if %}', encoding='utf-8') + output = tmp_path / 'template.pot' + + result = runner.invoke(main, args=['pot', template_path.as_posix(), + '-o', output.as_posix()]) + assert result.exit_code == 0 + assert 'Skipped file src/broken.j2 that could not be parsed' in result.output + + content = output.read_text(encoding='utf-8') + assert 'Skipped files that could not be parsed: src/broken.j2' in content + assert 'msgid "This is example"' in content diff --git a/packages/dsw-tdk/tests/test_model_language.py b/packages/dsw-tdk/tests/test_model_language.py new file mode 100644 index 00000000..aa7b2ca3 --- /dev/null +++ b/packages/dsw-tdk/tests/test_model_language.py @@ -0,0 +1,43 @@ +import collections + +from dsw.tdk.model import Template +from dsw.tdk.validation import TemplateValidator + + +def load(**extra) -> Template: + data = collections.OrderedDict({ + 'organizationId': 'test', + 'templateId': 'example', + 'version': '1.0.0', + 'name': 'Test template', + 'description': 'Testing', + 'license': 'Apache-2.0', + 'readme': 'Readme', + 'metamodelVersion': '18.3', + 'formats': [], + **extra, + }) + return Template.load_local(data) + + +def errors_for(template: Template, field: str) -> list[str]: + return [e.message for e in TemplateValidator.collect_errors(template) + if e.field_name == field] + + +def test_language_defaults_when_absent(): + assert load().language == 'en' + + +def test_language_is_used_when_given(): + assert load(language='cs').language == 'cs' + + +def test_empty_language_is_not_silently_defaulted(): + template = load(language='') + assert template.language == '' + assert errors_for(template, 'language') != [] + + +def test_new_template_defaults_to_en(): + assert Template().language == 'en' diff --git a/uv.lock b/uv.lock index 43e7651d..800d836d 100644 --- a/uv.lock +++ b/uv.lock @@ -500,6 +500,7 @@ requires-dist = [ name = "dsw-document-worker" source = { editable = "packages/dsw-document-worker" } dependencies = [ + { name = "babel" }, { name = "click" }, { name = "dsw-command-queue" }, { name = "dsw-config" }, @@ -512,6 +513,7 @@ dependencies = [ { name = "panflute" }, { name = "pathvalidate" }, { name = "pluggy" }, + { name = "polib" }, { name = "pymdown-extensions" }, { name = "python-dateutil" }, { name = "python-slugify" }, @@ -531,6 +533,7 @@ test = [ [package.metadata] requires-dist = [ + { name = "babel" }, { name = "click" }, { name = "dsw-command-queue", editable = "packages/dsw-command-queue" }, { name = "dsw-config", editable = "packages/dsw-config" }, @@ -543,6 +546,7 @@ requires-dist = [ { name = "panflute" }, { name = "pathvalidate" }, { name = "pluggy" }, + { name = "polib" }, { name = "pymdown-extensions" }, { name = "pytest", marker = "extra == 'test'" }, { name = "python-dateutil" }, @@ -626,6 +630,7 @@ name = "dsw-tdk" source = { editable = "packages/dsw-tdk" } dependencies = [ { name = "aiohttp" }, + { name = "babel" }, { name = "click" }, { name = "colorama" }, { name = "humanize" }, @@ -647,6 +652,7 @@ test = [ [package.metadata] requires-dist = [ { name = "aiohttp" }, + { name = "babel" }, { name = "click" }, { name = "colorama" }, { name = "humanize" },