Repository navigation
Conversation
|
Thanks for the pull request, @efortish! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
|
I'm not fully familiar with the usual Translation strings extraction process on other repos, but should it be run on a CI step? should that CI step be implemented here to call the make extract_translations automatically? |
rodmgwgu
left a comment
There was a problem hiding this comment.
Thanks for this work, looking good, just added some improvements and notes.
Also, can we add an end-to-end test for the translation extraction process? Perhaps a test that runs extract_schema_translations -> makemessages and asserts a catalog entry like msgctxt "role.display_name:course_admin" / msgid "Course Admin".
| """A schema resource could not be read or parsed for extraction.""" | ||
|
|
||
|
|
||
| def extract_messages(resources: list[DiscoveredResource] | None = None) -> list[TranslatableMessage]: |
There was a problem hiding this comment.
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.
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.
A lighter version of what I suggested could be having a TranslatableDocument class 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.
| # Last one wins on a context collision; in practice this only happens | ||
| # if two files genuinely define the same stable id, which validation | ||
| # (a separate step) is responsible for catching. | ||
| messages[extracted.context] = extracted |
There was a problem hiding this comment.
What would happen if there are two with the same context? Would this be overriden?
There was a problem hiding this comment.
Previously: silently overridden, whichever file sorted last won, which is exactly the bug your test table caught below (the priority-100-beats-priority-200 case). Fixed now: conflicts resolve by priority the same way engine.schema.compilation does (higher wins, identical content merges, equal priority with disagreeing text raises).
There was a problem hiding this comment.
Thanks for addressing this! I still wonder if we could just extract all occurrences without resolving priorities. I feel like we're adding more responsibility to the translation process than needed. Ideally, it shouldn't need to know about priorities or how schema compilation works. What do you think?
| def _extract_from_resource(resource: DiscoveredResource) -> list[TranslatableMessage]: | ||
| """Parse one resource's YAML and pull its translatable fields out.""" | ||
| 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 {resource.package}:{resource.resource_path}: {exc}" | ||
| ) from exc |
There was a problem hiding this comment.
I think this is already done by discovery, no?
Can we reuse that somehow?
There was a problem hiding this comment.
Good catch that these look like the same problem, but they're solving different ones: discovery's sort gives a deterministic order to read files in (so the same directories always produce the same list), it doesn't resolve conflicts between what two files say about the same role/permission. That's now handled explicitly by priority (see the thread below), independent of file order or discovery's sort entirely, so reusing discovery's sort wouldn't actually have fixed the bug you found.
There was a problem hiding this comment.
What I wouldn't like is for us to always have to extract from resource each time we need to load the schema.
| messages.extend(_messages_for_entry("permission_category", category.get("id", ""), category)) | ||
| for permission in document.get("permissions") or []: | ||
| stable_id = f"{permission.get('namespace', '')}.{permission.get('name', '')}" | ||
| messages.extend(_messages_for_entry("permission", stable_id, permission)) | ||
| for role in document.get("roles") or []: | ||
| messages.extend(_messages_for_entry("role", role.get("id", ""), role)) | ||
| for extension in document.get("role_extensions") or []: | ||
| # display_name/description are optional on an extension (it may only | ||
| # touch permissions or the icon), so only emit the fields it actually | ||
| # overrides instead of a pair of empty messages for every extension. | ||
| messages.extend(_messages_for_entry("role_extension", extension.get("role", ""), extension, optional=True)) |
There was a problem hiding this comment.
I think these entries should be part of a class instead. Maybe each part of the schema is a translatable component that knows how to extract itself.
There was a problem hiding this comment.
Same as the other thread on this, ended up with a data table (document key, field_kind, stable-id getter) rather than a class per section, since the four sections don't actually behave differently, they just read different fields. Open to a class if that stops being true.
|
I tested the extraction end to end by adding test schemas next to the real schema through With the current schema it generates 152 messages, which matches the YAML (17 categories, 48 permissions and 11 roles, each with a display name and a description). These are the edge cases I tried:
None of the cases with exit 0 print a warning. It's not the end of the world if extraction doesn't validate the schema's shape, since that isn't its responsibility, but it shouldn't fail catastrophically either or generate a valid file without any warning. I also think duplicates should be handled differently. At this point we don't know what the final schema will look like, so replacing one translation with another could make us lose context. The test schemas and the script to run them are here if you want to reproduce any case: https://github.com/edunext/openedx-authz/tree/MJG/schema-i18n-extraction/src/openedx_authz/tests/schema/i18n_extraction_cases. This could be used for the end-to-end testing :)) |
ADR 0020 asks for the static schema's display_name/description fields to go through OEP-58 translation. Standard extraction tools can't see them because they live in YAML, not Python or JavaScript source. Add a management command that discovers every schema resource (reusing the already-merged SchemaDiscovery step), parses its YAML directly, and writes a generated Python module with one pgettext() call per field, for i18n_tool extract (Django's makemessages) to pick up. pgettext is used instead of plain gettext because ADR 0020 requires a field's display name and description to stay separate messages even when they're identical text; the context string also carries the field's stable id, so the same one-word name on two different roles stays separate too. Wired into the extract_translations Makefile target, ahead of the existing i18n_tool extract step. This only covers the extraction/translation-source side (ADR 0020 points 1-2). Routing the translated strings back out through an API (points 3-4) isn't buildable yet: the schema loader/model layer that would read these fields at runtime, and the role/permission catalog API itself, are both still open PRs (openedx#475-480, openedx#508), not the already-merged discovery step this extraction reuses. Left a comment on openedx-authz#432 with that split and will file a follow-up once those land. This doesn't touch the schema load/validate/compile pipeline at all (intentionally): it only needs raw display_name/description fields, not resolved role extensions or conflict handling, so it stays usable and testable independent of that still-unmerged pipeline.
The management command had zero test coverage, which dropped overall project coverage and failed the codecov check on this PR. Add tests that invoke it through call_command for both the happy path (writes a valid generated module) and the failure path (a CommandError on extraction failure), and add the one remaining uncovered branch in translation.py: an empty YAML file parsing to None instead of a dict.
…ction Addresses review feedback on the extraction mechanism: - A malformed section (not a list) or entry (not a mapping) now raises SchemaTranslationExtractionError instead of an AttributeError from deep inside a .get() call on a string or int. - An entry missing its identifying field(s) (a role without id, a permission without namespace/name, etc.) now raises, instead of silently producing duplicate or anonymous contexts. - Conflicts between two documents defining the same role/permission/ category now resolve by each document's priority, the same rule engine.schema.compilation uses: higher priority wins, identical content merges, and a disagreement at equal priority raises. This replaces resolving by file path order, which didn't necessarily match the winner the compiled schema would actually serve. - A display_name/description containing gettext's own msgctxt/msgid separator byte (U+0004) now raises instead of generating a module that's valid Python but breaks msguniq once makemessages runs it. - A missing display_name/description is now skipped for every entry kind, not just role_extensions, instead of generating a useless empty pgettext() call. - The four schema sections (permission_categories, permissions, roles, role_extensions) are now driven by one data table instead of repeated per-section code, since they only differ in three things: the document key, the context's field_kind label, and how to read the entry's stable id. - Added an end-to-end test exercising the real extract_schema_translations -> makemessages pipeline, skipped where the system gettext tools it shells out to aren't installed. Also updates the module docstring and PR description: the schema loader/validate/compile pipeline this module was written to stay independent of is merged now, and the role/permission catalog API has its own PR (openedx#510).
914a86b to
570592f
Compare
|
@rodmgwgu addressing your review: End-to-end test: added. CI step for The other four inline comments (malformed-collection crash, empty-field pgettext, missing stable id, and the stale PR-dependency comment) are fixed too, replied on each thread individually. |
|
@mariajgrimaldi thank you for the extremely thorough edge-case pass, this caught a real correctness bug. Going through your table: The important one: "Same role id in two files, priority 200 vs priority 100 → the priority 100 file wins because it sorts last by path." That's a genuine bug, extraction was resolving conflicts by file path order, which doesn't necessarily match the winner the compiled schema would actually serve. Fixed: conflicts now resolve by each document's Control characters (tab, \r, \x04): I reproduced this directly against real Non-mapping entries / sections ( Missing fields (category without description, etc.): agreed this isn't extraction's job to require. Went further than "don't fail catastrophically" though: a missing field is now skipped outright rather than generating a Invalid YAML: unchanged, still a I didn't pull in your test schemas branch directly, instead wrote focused unit tests for each scenario against the new behavior (shape validation, missing ids, priority resolution, the |
The new end-to-end test in test_commands.py imports polib to parse the generated .po file. It was only ever present transitively through edx-i18n-tools, which is in the dev dependency group, not test, so CI's django52 and quality jobs failed importing test_commands.py with ModuleNotFoundError.
|
@rodmgwgu: to confirm, were you referring to this kind of end to end tests https://github.com/openedx/end-to-end-tests/ or unittests (simulating the extraction process)? |
I was referring to unit tests exercising the extraction process. |
Description
ADR 0020 asks for the static schema's
display_name/descriptionfields to go through OEP-58 translation. Standard extraction tools can't see them because they live in YAML, not Python or JavaScript source.Adds a management command (
extract_schema_translations) that discovers every schema resource (reusing the already-mergedSchemaDiscoverystep from #474), parses its YAML directly, and writes a generated Python module with onepgettext()call per translatable field, fori18n_tool extract(Django'smakemessages) to pick up.pgettextinstead of plaingettextbecause ADR 0020 requires a field's display name and description to stay separate messages even when they're identical text; the context string also carries the field's stable id, so the same one-word name on two different roles (e.g. "Admin") stays separate too. Wired into theextract_translationsMakefile target, ahead of the existingi18n_tool extractstep.Conflicts between files (the same role or permission defined twice) resolve by each document's
priority, the same ruleengine.schema.compilationuses: higher priority wins, identical definitions merge, and a disagreement at equal priority is a hard error. Extraction re-derives this rule itself instead of depending on the compiler, so the generated catalog matches what the compiled schema would actually serve without coupling extraction to that pipeline's internals.Scope: this only covers extraction (ADR 0020 points 1-2)
Routing the translated strings back out through an API (points 3-4 of openedx-authz#432) isn't done here. The schema loader/model/compile layer (#475, #476, #477, #478) is merged now, and the role/permission catalog API has its own PR, #510, but that endpoint currently serves the raw YAML text as-is; it doesn't yet look values up through the compiled translation catalog with
pgettext. Left a comment on #432 with that split; will file a follow-up issue for points 3-4 once #510 lands.This deliberately doesn't use the schema load/validate/compile pipeline's typed documents: it only needs raw
display_name/descriptionfields, not resolved role extensions, so it stays usable and testable against ad hoc or malformed schema directories too, not just a fully valid one.Closes openedx-authz#432 (partially, see scope note above)