-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
feat: atlas pull
for XBlock translations | FC-0012
#33698
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
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 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 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
22 changes: 22 additions & 0 deletions
22
common/djangoapps/xblock_django/management/commands/compile_xblock_translations.py
This file contains 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,22 @@ | ||
""" | ||
Compile the translation files for the XBlocks. | ||
""" | ||
|
||
from django.core.management.base import BaseCommand | ||
|
||
from xmodule.modulestore import api as xmodule_api | ||
|
||
from openedx.core.djangoapps.plugins.i18n_api import compile_po_files | ||
|
||
from ...translation import ( | ||
compile_xblock_js_messages, | ||
) | ||
|
||
|
||
class Command(BaseCommand): | ||
""" | ||
Compile the translation files for the XBlocks. | ||
""" | ||
def handle(self, *args, **options): | ||
compile_po_files(xmodule_api.get_python_locale_root()) | ||
compile_xblock_js_messages() |
51 changes: 51 additions & 0 deletions
51
common/djangoapps/xblock_django/management/commands/pull_xblock_translations.py
This file contains 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,51 @@ | ||
""" | ||
Download the translations via atlas for the XBlocks. | ||
""" | ||
|
||
from django.core.management.base import BaseCommand, CommandError | ||
|
||
from openedx.core.djangoapps.plugins.i18n_api import ATLAS_ARGUMENTS | ||
from xmodule.modulestore import api as xmodule_api | ||
|
||
from ...translation import xblocks_atlas_pull | ||
|
||
|
||
class Command(BaseCommand): | ||
""" | ||
Pull the XBlock translations via atlas for the XBlocks. | ||
|
||
For detailed information about atlas pull options check the atlas documentation: | ||
|
||
- https://github.com/openedx/openedx-atlas | ||
""" | ||
|
||
def add_arguments(self, parser): | ||
for argument in ATLAS_ARGUMENTS: | ||
parser.add_argument(*argument.get_args(), **argument.get_kwargs()) | ||
|
||
parser.add_argument( | ||
'--verbose|-v', | ||
action='store_true', | ||
default=False, | ||
dest='verbose', | ||
help='Verbose output using `--verbose` argument for `atlas pull`.', | ||
) | ||
|
||
def handle(self, *args, **options): | ||
xblock_translations_root = xmodule_api.get_python_locale_root() | ||
if list(xblock_translations_root.listdir()): | ||
raise CommandError(f'"{xblock_translations_root}" should be empty before running atlas pull.') | ||
|
||
atlas_pull_options = [] | ||
|
||
for argument in ATLAS_ARGUMENTS: | ||
option_value = options.get(argument.dest) | ||
if option_value is not None: | ||
atlas_pull_options += [argument.flag, option_value] | ||
|
||
if options['verbose']: | ||
atlas_pull_options += ['--verbose'] | ||
else: | ||
atlas_pull_options += ['--silent'] | ||
|
||
xblocks_atlas_pull(pull_options=atlas_pull_options) |
This file contains 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,70 @@ | ||
""" | ||
Tests for the pull_xblock_translations management command. | ||
""" | ||
|
||
from path import Path | ||
from unittest.mock import patch | ||
|
||
from django.core.management import call_command | ||
|
||
from done import DoneXBlock | ||
|
||
from xmodule.modulestore.api import ( | ||
get_python_locale_root, | ||
get_javascript_i18n_file_path, | ||
) | ||
from xmodule.modulestore.tests.conftest import tmp_translations_dir | ||
|
||
|
||
def test_pull_xblock_translations(tmp_path): | ||
""" | ||
Test the compile_xblock_translations management command. | ||
""" | ||
temp_xblock_locale_path = Path(str(tmp_path)) | ||
|
||
with patch('common.djangoapps.xblock_django.translation.get_non_xmodule_xblocks') as mock_get_non_xmodule_xblocks: | ||
with patch('xmodule.modulestore.api.get_python_locale_root') as mock_get_python_locale_root: | ||
with patch('subprocess.run') as mock_run: | ||
mock_get_python_locale_root.return_value = Path(str(temp_xblock_locale_path)) | ||
mock_get_non_xmodule_xblocks.return_value = [('done', DoneXBlock)] | ||
|
||
call_command( | ||
'pull_xblock_translations', | ||
filter='ar,de_DE,jp', | ||
repository='openedx/custom-translations', | ||
branch='release/redwood', | ||
) | ||
|
||
assert mock_run.call_count == 1, 'Calls `subprocess.run`' | ||
assert mock_run.call_args.kwargs['args'] == [ | ||
'atlas', 'pull', | ||
'--expand-glob', | ||
'--filter', 'ar,de_DE,jp', | ||
'--repository', 'openedx/custom-translations', | ||
'--branch', 'release/redwood', | ||
'--silent', | ||
'translations/*/done/conf/locale:done', | ||
] | ||
|
||
|
||
def test_compile_xblock_translations(tmp_translations_dir): | ||
""" | ||
Test the compile_xblock_translations management command. | ||
""" | ||
# msgfmt isn't available in test environment, so we mock the `subprocess.run` and copy the django.mo file, | ||
# it to ensure `compile_xblock_js_messages` can work. | ||
with tmp_translations_dir(xblocks=[('done', DoneXBlock)], fixtures_to_copy=['django.po', 'django.mo']): | ||
with patch.object(DoneXBlock, 'i18n_js_namespace', 'TestingDoneXBlockI18n'): | ||
po_file = get_python_locale_root() / 'done/tr/LC_MESSAGES/django.po' | ||
|
||
with patch('subprocess.run') as mock_run: | ||
call_command('compile_xblock_translations') | ||
assert mock_run.call_count == 1, 'Calls `subprocess.run`' | ||
assert mock_run.call_args.kwargs['args'] == [ | ||
'msgfmt', '--check-format', '-o', str(po_file.with_suffix('.mo')), str(po_file), | ||
], 'Compiles the .po files' | ||
|
||
js_file_text = get_javascript_i18n_file_path('done', 'tr').text() | ||
assert 'Merhaba' in js_file_text, 'Ensures the JavaScript catalog is compiled' | ||
assert 'TestingDoneXBlockI18n' in js_file_text, 'Ensures the namespace is used' | ||
assert 'gettext' in js_file_text, 'Ensures the gettext function is defined' |
This file contains 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,26 @@ | ||
""" | ||
Tests for the xblock_django.translation module. | ||
""" | ||
|
||
from done import DoneXBlock | ||
|
||
from ..translation import ( | ||
get_non_xmodule_xblock_module_names, | ||
get_non_xmodule_xblocks, | ||
) | ||
|
||
|
||
def test_get_non_xmodule_xblock_module_names(): | ||
""" | ||
Ensure xmodule isn't returned but other default xblocks are. | ||
""" | ||
assert 'xmodule' not in get_non_xmodule_xblock_module_names() | ||
assert 'done' in get_non_xmodule_xblock_module_names() | ||
assert 'lti_consumer' in get_non_xmodule_xblock_module_names() | ||
|
||
|
||
def test_get_non_xmodule_xblocks(): | ||
""" | ||
Ensures that default XBlocks are included. | ||
""" | ||
assert ('done', DoneXBlock) in get_non_xmodule_xblocks() |
This file contains 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,156 @@ | ||
""" | ||
XBlock translations pulling and compilation logic. | ||
""" | ||
|
||
import os | ||
import gettext | ||
|
||
from django.utils.encoding import force_str | ||
from django.views.i18n import JavaScriptCatalog | ||
from django.utils.translation import override, to_locale, get_language | ||
from statici18n.management.commands.compilejsi18n import Command as CompileI18NJSCommand | ||
from xblock.core import XBlock | ||
|
||
from openedx.core.djangoapps.plugins.i18n_api import atlas_pull_by_modules | ||
from xmodule.modulestore import api as xmodule_api | ||
|
||
|
||
class AtlasJavaScriptCatalog(JavaScriptCatalog): | ||
""" | ||
View to return the selected language catalog as a JavaScript library. | ||
|
||
This extends the JavaScriptCatalog class to allow custom domain and locale_dir. | ||
""" | ||
|
||
translation = None | ||
|
||
def get(self, request, *args, **kwargs): | ||
""" | ||
Return the selected language catalog as a JavaScript library. | ||
|
||
This overrides the JavaScriptCatalog.get() method class to allow custom locale_dir. | ||
""" | ||
selected_language = get_language() | ||
locale = to_locale(selected_language) | ||
domain = kwargs['domain'] | ||
locale_dir = kwargs['locale_dir'] | ||
# Using GNUTranslations instead of DjangoTranslation to allow custom locale_dir without needing | ||
# to use a custom `text.mo` translation domain. | ||
self.translation = gettext.translation(domain, localedir=locale_dir, languages=[locale]) | ||
context = self.get_context_data(**kwargs) | ||
return self.render_to_response(context) | ||
|
||
@classmethod | ||
def simulate_get_request(cls, locale, domain, locale_dir): | ||
""" | ||
Simulate a GET request to the JavaScriptCatalog view. | ||
|
||
Return: | ||
str: The rendered JavaScript catalog. | ||
""" | ||
with override(locale): | ||
catalog_view = cls() | ||
response = catalog_view.get( | ||
request=None, # we are passing None as the request, as the request | ||
# object is currently not used by django | ||
domain=domain, | ||
locale_dir=locale_dir, | ||
) | ||
return force_str(response.content) | ||
|
||
|
||
def mo_file_to_js_namespaced_catalog(xblock_conf_locale_dir, locale, domain, namespace): | ||
""" | ||
Compile .mo to .js gettext catalog and wrap it in a namespace via the `compilejsi18n` command helpers. | ||
""" | ||
rendered_js = AtlasJavaScriptCatalog.simulate_get_request( | ||
locale=locale, | ||
locale_dir=xblock_conf_locale_dir, | ||
domain=domain, | ||
) | ||
|
||
# The `django-statici18n` package has a non-standard code license, therefore we're using its private API | ||
# to avoid copying the code into this repository and running into licensing issues. | ||
compile_i18n_js_command = CompileI18NJSCommand() | ||
namespaced_catalog_js_code = compile_i18n_js_command._get_namespaced_catalog( # pylint: disable=protected-access | ||
rendered_js=rendered_js, | ||
namespace=namespace, | ||
) | ||
|
||
return namespaced_catalog_js_code | ||
|
||
|
||
def xblocks_atlas_pull(pull_options): | ||
""" | ||
Atlas pull the translations for the XBlocks that are installed. | ||
""" | ||
xblock_module_names = get_non_xmodule_xblock_module_names() | ||
|
||
atlas_pull_by_modules( | ||
module_names=xblock_module_names, | ||
locale_root=xmodule_api.get_python_locale_root(), | ||
pull_options=pull_options, | ||
) | ||
|
||
|
||
def compile_xblock_js_messages(): | ||
""" | ||
Compile the XBlock JavaScript messages from .mo file into .js files. | ||
""" | ||
for xblock_module, xblock_class in get_non_xmodule_xblocks(): | ||
xblock_conf_locale_dir = xmodule_api.get_python_locale_root() / xblock_module | ||
i18n_js_namespace = xblock_class.get_i18n_js_namespace() | ||
|
||
for locale_dir in xblock_conf_locale_dir.listdir(): | ||
locale_code = str(locale_dir.basename()) | ||
locale_messages_dir = locale_dir / 'LC_MESSAGES' | ||
js_translations_domain = None | ||
for domain in ['djangojs', 'django']: | ||
po_file_path = locale_messages_dir / f'{domain}.mo' | ||
if po_file_path.exists(): | ||
if not js_translations_domain: | ||
# Select which file to compile to `django.js`, while preferring `djangojs` over `django` | ||
js_translations_domain = domain | ||
|
||
if js_translations_domain and i18n_js_namespace: | ||
js_i18n_file_path = xmodule_api.get_javascript_i18n_file_path(xblock_module, locale_code) | ||
os.makedirs(js_i18n_file_path.dirname(), exist_ok=True) | ||
js_namespaced_catalog = mo_file_to_js_namespaced_catalog( | ||
xblock_conf_locale_dir=xblock_conf_locale_dir, | ||
locale=locale_code, | ||
domain=js_translations_domain, | ||
namespace=i18n_js_namespace, | ||
) | ||
|
||
with open(js_i18n_file_path, 'w', encoding='utf-8') as f: | ||
f.write(js_namespaced_catalog) | ||
|
||
|
||
def get_non_xmodule_xblocks(): | ||
""" | ||
Returns a list of XBlock classes with their module name excluding edx-platform/xmodule xblocks. | ||
""" | ||
xblock_classes = [] | ||
for _xblock_tag, xblock_class in XBlock.load_classes(): | ||
xblock_module_name = xmodule_api.get_root_module_name(xblock_class) | ||
if xblock_module_name != 'xmodule': | ||
# XBlocks in edx-platform/xmodule are already translated in edx-platform/conf/locale | ||
# So there is no need to add special handling for them. | ||
xblock_classes.append( | ||
(xblock_module_name, xblock_class), | ||
) | ||
|
||
return xblock_classes | ||
|
||
|
||
def get_non_xmodule_xblock_module_names(): | ||
""" | ||
Returns a list of module names for the plugins that supports translations excluding `xmodule`. | ||
""" | ||
xblock_module_names = set( | ||
xblock_module_name | ||
for xblock_module_name, _xblock_class in get_non_xmodule_xblocks() | ||
) | ||
|
||
sorted_xblock_module_names = list(sorted(xblock_module_names)) | ||
return sorted_xblock_module_names |
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.
Thanks @feanil for the quick triage.
Conflicts has been resolved and I've tested it again and it seems to be playing well.
I've changed this line to match the Tutor option to have one less surprise for operators.