Repository navigation
feat: extract translatable strings from the authz schema #509
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
efortish
wants to merge
4
commits into
openedx:main
Choose a base branch
from
eduNEXT:ks/issue-432-schema-i18n-extraction
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
6418a55
feat: extract translatable strings from the authz schema
efortish ace7ed2
test: cover the extract_schema_translations command and empty-file case
efortish 570592f
fix: validate schema shape and resolve conflicts by priority in extra…
efortish f528cc8
fix: add polib as a direct test dependency
efortish File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -114,6 +114,7 @@ test-base = [ | |
| "code-annotations", | ||
| "coverage", | ||
| "ddt", | ||
| "polib", | ||
| "pytest-cov", | ||
| "pytest-django", | ||
| ] | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| """Extract translatable strings from the static authz schema (ADR 0020). | ||
|
|
||
| The authz schema's ``display_name`` and ``description`` fields are source-language | ||
| text that end users see through APIs and frontend clients, so they need to go | ||
| through the Open edX translation process (OEP-58). Standard extraction tools only | ||
| scan Python and JavaScript source, so they can't find strings written in YAML. | ||
|
|
||
| This module reads every discovered schema resource directly with ``yaml.safe_load``, | ||
| independent of the schema ``load``/``validate``/``compile`` pipeline: it only needs | ||
| the raw ``display_name``/``description`` fields, not the richer typed documents | ||
| those steps build, so it stays usable (and testable) against ad hoc or malformed | ||
| schema directories, not just a fully valid one. It does, however, resolve conflicts | ||
| between files the same way ``engine.schema.compilation`` does: by each document's | ||
| ``priority`` (higher wins; identical content merges; a disagreement at equal | ||
| priority is an error), so the generated catalog reflects the same winner the | ||
| compiled schema would use at runtime. Re-deriving that one rule here (rather than | ||
| depending on the loader/compiler) is what keeps this module usable before, or | ||
| independently of, that pipeline. | ||
|
|
||
| It emits a generated Python module containing one ``pgettext()`` call per | ||
| translatable field, which ``i18n_tool extract`` (a thin wrapper around Django's | ||
| ``makemessages``) then picks up like any other source file. ``pgettext`` is used | ||
| instead of plain ``gettext`` because ADR 0020 requires a permission's display name | ||
| and description to remain separate translation messages even when they contain | ||
| the same English text; a plain ``gettext`` call would merge identical msgids into | ||
| one catalog entry. The first ``pgettext()`` argument is gettext's ``msgctxt``: it | ||
| carries the field's stable identifier, so two different roles that happen to share | ||
| a one-word name like "Admin" stay separate messages too. | ||
|
|
||
| This generated module is never imported or executed: its only purpose is to be | ||
| valid Python source for ``makemessages`` to scan. At runtime, the schema catalog | ||
| API looks up a field's source-language value directly with the same | ||
| ``pgettext(context, value)`` pair used here, so the generated calls must stay in | ||
| sync with however that lookup builds its context string. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Callable | ||
| from dataclasses import dataclass | ||
|
|
||
| import yaml | ||
|
|
||
| from openedx_authz.engine.schema.discovery import DiscoveredResource, SchemaDiscovery, SchemaDiscoveryError | ||
|
|
||
| GENERATED_FILE_HEADER = '''"""Generated by extract_schema_translations. Do not edit, do not import. | ||
|
|
||
| This file exists only so that ``i18n_tool extract`` (Django's ``makemessages``) | ||
| can find the authz schema's source-language display_name/description strings, | ||
| which otherwise live in YAML and are invisible to it. Regenerate it with: | ||
|
|
||
| python manage.py extract_schema_translations | ||
|
|
||
| See openedx_authz.engine.schema.translation for why pgettext is used here. | ||
| """ | ||
| from django.utils.translation import pgettext | ||
| ''' | ||
|
|
||
| # gettext's own internal glue byte between msgctxt and msgid. If it ends up inside | ||
| # either half, tools downstream of extraction (msguniq, as part of `makemessages`) | ||
| # fail outright trying to parse the result back apart, so it can never appear in | ||
| # generated content. | ||
| GETTEXT_CONTEXT_SEPARATOR = "\x04" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class TranslatableMessage: | ||
| """One ``pgettext(context, message)`` pair extracted from the schema. | ||
|
|
||
| Attributes: | ||
| context: gettext's ``msgctxt``. Disambiguates otherwise-identical | ||
| messages, formatted as ``"{field_kind}.{field_name}:{stable_id}"``, | ||
| e.g. ``"permission.display_name:courses.view_course"``. | ||
| message: gettext's ``msgid``, the source-language (English) string to | ||
| translate. | ||
| """ | ||
|
|
||
| context: str | ||
| message: str | ||
|
|
||
|
|
||
| class SchemaTranslationExtractionError(Exception): | ||
| """A schema resource could not be read, parsed, or resolved for extraction.""" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class _Candidate: | ||
| """A :class:`TranslatableMessage` plus the provenance needed to resolve conflicts.""" | ||
|
|
||
| message: TranslatableMessage | ||
| priority: int | ||
| source: str | ||
|
|
||
|
|
||
| def _permission_stable_id(entry: dict) -> str: | ||
| """Build a permission's stable id from its namespace and name, both required.""" | ||
| namespace = entry.get("namespace") | ||
| name = entry.get("name") | ||
| if not namespace or not name: | ||
| return "" | ||
| return f"{namespace}.{name}" | ||
|
|
||
|
|
||
| # Each entry is (the document key holding this section's list, the field_kind used | ||
| # in the generated context string, a function pulling this entry's stable id out of | ||
| # its raw mapping). All four sections share the same display_name/description | ||
| # extraction logic (_messages_for_entry); only these three things differ between | ||
| # them, so a data table covers it without a class per section. | ||
| _SECTIONS: tuple[tuple[str, str, Callable[[dict], str]], ...] = ( | ||
| ("permission_categories", "permission_category", lambda entry: str(entry.get("id") or "")), | ||
| ("permissions", "permission", _permission_stable_id), | ||
| ("roles", "role", lambda entry: str(entry.get("id") or "")), | ||
| ("role_extensions", "role_extension", lambda entry: str(entry.get("role") or "")), | ||
| ) | ||
|
|
||
|
|
||
| def extract_messages(resources: list[DiscoveredResource] | None = None) -> list[TranslatableMessage]: | ||
| """Extract every translatable schema string from the given (or discovered) resources. | ||
|
|
||
| Args: | ||
| resources: Resources to extract from. Defaults to everything | ||
| ``SchemaDiscovery`` finds (every registered schema directory), which | ||
| is what the management command uses. | ||
|
mariajgrimaldi marked this conversation as resolved.
|
||
|
|
||
| Returns: | ||
| Deduplicated messages, sorted by context, so the generated file's | ||
| diffs are stable across runs regardless of discovery order. | ||
|
|
||
| Raises: | ||
| SchemaTranslationExtractionError: A resource's bytes couldn't be read, it | ||
| wasn't valid YAML, a section wasn't shaped as the schema requires, an | ||
| entry was missing its identifying field, a translatable string | ||
| collided with gettext's own context separator, or two documents | ||
| disagreed on the same field at equal priority. A translation build | ||
| must fail loudly here (ADR 0020) rather than silently produce a | ||
| catalog that doesn't match what the compiled schema would serve. | ||
| """ | ||
| if resources is None: | ||
| try: | ||
| resources = SchemaDiscovery().discover() | ||
| except SchemaDiscoveryError as exc: | ||
| raise SchemaTranslationExtractionError(str(exc)) from exc | ||
|
|
||
| resolved: dict[str, _Candidate] = {} | ||
| for resource in resources: | ||
| for candidate in _extract_from_resource(resource): | ||
| existing = resolved.get(candidate.message.context) | ||
| resolved[candidate.message.context] = ( | ||
| candidate if existing is None else _resolve_conflict(existing, candidate) | ||
| ) | ||
|
|
||
| return sorted((candidate.message for candidate in resolved.values()), key=lambda m: m.context) | ||
|
|
||
|
|
||
| def _resolve_conflict(existing: _Candidate, incoming: _Candidate) -> _Candidate: | ||
| """Resolve two competing messages for the same context, the way compilation.py does. | ||
|
|
||
| Identical messages merge (both files contributed the same text); otherwise | ||
| higher ``priority`` wins; equal priority with disagreeing text is unresolvable. | ||
| """ | ||
| if existing.message == incoming.message: | ||
| return existing | ||
| if existing.priority == incoming.priority: | ||
| raise SchemaTranslationExtractionError( | ||
| f"Conflicting translation for {existing.message.context!r} at equal priority " | ||
| f"{existing.priority} ({existing.source} vs {incoming.source}): " | ||
| f"{existing.message.message!r} vs {incoming.message.message!r}." | ||
| ) | ||
| return existing if existing.priority > incoming.priority else incoming | ||
|
|
||
|
|
||
| def _extract_from_resource(resource: DiscoveredResource) -> list[_Candidate]: | ||
| """Parse one resource's YAML and pull its translatable fields out.""" | ||
| source = f"{resource.package}:{resource.resource_path}" | ||
| try: | ||
| contents = resource.read_bytes() | ||
| except SchemaDiscoveryError as exc: | ||
| raise SchemaTranslationExtractionError(str(exc)) from exc | ||
|
|
||
| try: | ||
| document = yaml.safe_load(contents) | ||
| except yaml.YAMLError as exc: | ||
| raise SchemaTranslationExtractionError(f"Invalid YAML in {source}: {exc}") from exc | ||
|
|
||
| if document is None: | ||
| return [] | ||
| if not isinstance(document, dict): | ||
| raise SchemaTranslationExtractionError( | ||
| f"Schema file {source} must be a mapping at the top level, got {type(document).__name__}." | ||
| ) | ||
|
|
||
| # Required per authz-schema-v1.json; enforcing its presence is validation's job, | ||
| # not extraction's (same split loading.py makes), so a missing priority reads as | ||
| # 0, the lowest precedence. A malformed one can't be resolved at all, so that | ||
| # raises now rather than producing a nonsensical conflict winner later. | ||
| try: | ||
| priority = int(document.get("priority", 0)) | ||
| except (TypeError, ValueError) as exc: | ||
| raise SchemaTranslationExtractionError( | ||
| f"{source}: 'priority' must be an integer, got {document.get('priority')!r}." | ||
| ) from exc | ||
|
|
||
| candidates: list[_Candidate] = [] | ||
| for section_key, field_kind, stable_id_of in _SECTIONS: | ||
| entries = document.get(section_key) or [] | ||
| if not isinstance(entries, list): | ||
| raise SchemaTranslationExtractionError( | ||
| f"{source}: {section_key!r} must be a list, got {type(entries).__name__}." | ||
| ) | ||
| for entry in entries: | ||
| if not isinstance(entry, dict): | ||
| raise SchemaTranslationExtractionError( | ||
| f"{source}: an entry in {section_key!r} must be a mapping, got {type(entry).__name__}." | ||
| ) | ||
| stable_id = stable_id_of(entry) | ||
| if not stable_id: | ||
| raise SchemaTranslationExtractionError( | ||
| f"{source}: a {field_kind} entry in {section_key!r} is missing its identifying field(s)." | ||
| ) | ||
| for message in _messages_for_entry(source, field_kind, stable_id, entry): | ||
| candidates.append(_Candidate(message=message, priority=priority, source=source)) | ||
|
|
||
| return candidates | ||
|
|
||
|
|
||
| def _messages_for_entry(source: str, field_kind: str, stable_id: str, entry: dict) -> list[TranslatableMessage]: | ||
| """Build the display_name/description messages for one schema entry. | ||
|
|
||
| A field that's absent (common on a ``role_extension``, which only overrides | ||
| what it mentions, but just as possible on any other entry) is skipped rather | ||
| than emitting an empty message: requiring these fields is validation's job, | ||
| not extraction's, so there's nothing useful to translate either way. | ||
| """ | ||
| messages = [] | ||
| for field_name in ("display_name", "description"): | ||
| value = entry.get(field_name) | ||
| if value is None: | ||
| continue | ||
| context = f"{field_kind}.{field_name}:{stable_id}" | ||
| message = str(value) | ||
| for part, label in ((context, "context"), (message, "text")): | ||
| if GETTEXT_CONTEXT_SEPARATOR in part: | ||
| raise SchemaTranslationExtractionError( | ||
| f"{source}: the {label} for {context!r} contains U+0004, which gettext reserves as its " | ||
| "own context separator and cannot appear in translatable content." | ||
| ) | ||
| messages.append(TranslatableMessage(context=context, message=message)) | ||
| return messages | ||
|
|
||
|
|
||
| def render_module(messages: list[TranslatableMessage]) -> str: | ||
| """Render the extracted messages as a generated Python module's source text.""" | ||
| lines = [GENERATED_FILE_HEADER] | ||
| for extracted in messages: | ||
| lines.append(f"pgettext({extracted.context!r}, {extracted.message!r})") | ||
| lines.append("") # Trailing newline. | ||
| return "\n".join(lines) | ||
59 changes: 59 additions & 0 deletions
59
src/openedx_authz/management/commands/extract_schema_translations.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| """Django management command to extract translatable strings from the authz schema. | ||
|
|
||
| See openedx_authz.engine.schema.translation for why this exists and how it works. | ||
| """ | ||
|
|
||
| import os | ||
|
|
||
| from django.core.management.base import BaseCommand, CommandError | ||
|
|
||
| from openedx_authz import ROOT_DIRECTORY | ||
| from openedx_authz.engine.schema.translation import ( | ||
| SchemaTranslationExtractionError, | ||
| extract_messages, | ||
| render_module, | ||
| ) | ||
|
|
||
| DEFAULT_OUTPUT_PATH = os.path.join(ROOT_DIRECTORY, "engine", "schema", "_generated_translations.py") | ||
|
|
||
|
|
||
| class Command(BaseCommand): | ||
| """Generate a Python module with one pgettext() call per translatable schema string. | ||
|
|
||
| Run this before ``i18n_tool extract`` (see the ``extract_translations`` Makefile | ||
| target), so the generated calls are on disk for ``makemessages`` to scan. | ||
|
|
||
| Example Usage: | ||
| python manage.py extract_schema_translations | ||
| python manage.py extract_schema_translations --output /path/to/file.py | ||
| """ | ||
|
|
||
| help = "Extract translatable display_name/description strings from the authz schema into a generated module." | ||
|
|
||
| def add_arguments(self, parser) -> None: | ||
| """Add command-line arguments to the argument parser. | ||
|
|
||
| Args: | ||
| parser: The Django argument parser instance to configure. | ||
| """ | ||
| parser.add_argument( | ||
| "--output", | ||
| type=str, | ||
| default=DEFAULT_OUTPUT_PATH, | ||
| help="Path to write the generated module to.", | ||
| ) | ||
|
|
||
| def handle(self, *args, **options) -> None: | ||
| """Extract schema translation messages and write the generated module.""" | ||
| try: | ||
| messages = extract_messages() | ||
| except SchemaTranslationExtractionError as exc: | ||
| raise CommandError(str(exc)) from exc | ||
|
|
||
| output_path = options["output"] | ||
| with open(output_path, "w", encoding="utf-8") as output_file: | ||
| output_file.write(render_module(messages)) | ||
|
|
||
| self.stdout.write( | ||
| self.style.SUCCESS(f"Extracted {len(messages)} translatable schema string(s) to {output_path}") | ||
| ) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if all these methods could be part of a translatable schema class so we also encapsulate within the class what are the translatable fields.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Went with a lighter-weight version of this: the four sections now share one data table (document key, field_kind label, a function to pull the stable id) instead of repeated per-section code, since behavior is identical across all of them and only those three things differ. Stopped short of a class per section since there's no actual per-section behavior to encapsulate, just data, happy to revisit if that changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A lighter version of what I suggested could be having a
TranslatableDocumentclass instead of separate classes for each section. That way, we could encapsulate the extraction process a bit more and make it easier to extend or maintain later on.I don't want to block this further though.