From e38f3eca48f45d09dccbdc439c1d4cb13c0f8ef3 Mon Sep 17 00:00:00 2001 From: Muhammad Farhan Khan Date: Mon, 20 May 2024 14:37:45 +0500 Subject: [PATCH 1/6] chore: Transition from pkg_resources API to importlib-resources API --- xblock/__init__.py | 2 +- xblock/core.py | 10 ++++++++-- xblock/plugin.py | 10 +++++++--- xblock/test/test_core.py | 17 ++++++++--------- xblock/test/utils/test_resources.py | 6 +++--- xblock/utils/resources.py | 13 +++++++------ 6 files changed, 34 insertions(+), 24 deletions(-) diff --git a/xblock/__init__.py b/xblock/__init__.py index e679b053b..6ac9bd16f 100644 --- a/xblock/__init__.py +++ b/xblock/__init__.py @@ -2,4 +2,4 @@ XBlock Courseware Components """ -__version__ = '4.1.0' +__version__ = '4.1.1' diff --git a/xblock/core.py b/xblock/core.py index fcea0f597..578d8ad60 100644 --- a/xblock/core.py +++ b/xblock/core.py @@ -10,7 +10,7 @@ import warnings from collections import OrderedDict, defaultdict -import pkg_resources +import importlib.resources from lxml import etree from webob import Response @@ -157,7 +157,13 @@ def open_local_resource(cls, uri): if "/." in uri: raise DisallowedFileError("Only safe file names are allowed: %r" % uri) - return pkg_resources.resource_stream(cls.__module__, os.path.join(cls.resources_dir, uri)) + return cls.open_resource(uri) + + @classmethod + def open_resource(cls, uri): + return importlib.resources.files(inspect.getmodule(cls).__package__).joinpath( + os.path.join(cls.resources_dir, uri) + ).open('rb') @classmethod def json_handler(cls, func): diff --git a/xblock/plugin.py b/xblock/plugin.py index 42f1ca6ce..bc10c3347 100644 --- a/xblock/plugin.py +++ b/xblock/plugin.py @@ -4,9 +4,9 @@ This code is in the Runtime layer. """ import functools +import importlib.metadata import itertools import logging -import pkg_resources from xblock.internal import class_lazy @@ -100,7 +100,11 @@ def select(identifier, all_entry_points): if select is None: select = default_select - all_entry_points = list(pkg_resources.iter_entry_points(cls.entry_point, name=identifier)) + all_entry_points = [ + entry_point + for entry_point in importlib.metadata.entry_points(group=cls.entry_point) + if entry_point.name == identifier + ] for extra_identifier, extra_entry_point in iter(cls.extra_entry_points): if identifier == extra_identifier: all_entry_points.append(extra_entry_point) @@ -133,7 +137,7 @@ def load_classes(cls, fail_silently=True): contexts. Hence, the flag. """ all_classes = itertools.chain( - pkg_resources.iter_entry_points(cls.entry_point), + importlib.metadata.entry_points(group=cls.entry_point), (entry_point for identifier, entry_point in iter(cls.extra_entry_points)), ) for class_ in all_classes: diff --git a/xblock/test/test_core.py b/xblock/test/test_core.py index c6a6fed10..4abaa241a 100644 --- a/xblock/test/test_core.py +++ b/xblock/test/test_core.py @@ -961,10 +961,9 @@ class UnloadableXBlock(XBlock): """Just something to load resources from.""" resources_dir = None - def stub_resource_stream(self, module, name): - """Act like pkg_resources.resource_stream, for testing.""" - assert module == "xblock.test.test_core" - return "!" + name + "!" + def stub_open_resource(self, uri): + """Act like xblock.core.Blocklike.open_resource, for testing.""" + return "!" + uri + "!" @ddt.data( "public/hey.js", @@ -976,7 +975,7 @@ def stub_resource_stream(self, module, name): ) def test_open_good_local_resource(self, uri): loadable = self.LoadableXBlock(None, scope_ids=Mock()) - with patch('pkg_resources.resource_stream', self.stub_resource_stream): + with patch('xblock.core.Blocklike.open_resource', self.stub_open_resource): assert loadable.open_local_resource(uri) == "!" + uri + "!" assert loadable.open_local_resource(uri.encode('utf-8')) == "!" + uri + "!" @@ -990,7 +989,7 @@ def test_open_good_local_resource(self, uri): ) def test_open_good_local_resource_binary(self, uri): loadable = self.LoadableXBlock(None, scope_ids=Mock()) - with patch('pkg_resources.resource_stream', self.stub_resource_stream): + with patch('xblock.core.Blocklike.open_resource', self.stub_open_resource): assert loadable.open_local_resource(uri) == "!" + uri.decode('utf-8') + "!" @ddt.data( @@ -1004,7 +1003,7 @@ def test_open_good_local_resource_binary(self, uri): ) def test_open_bad_local_resource(self, uri): loadable = self.LoadableXBlock(None, scope_ids=Mock()) - with patch('pkg_resources.resource_stream', self.stub_resource_stream): + with patch('xblock.core.Blocklike.open_resource', self.stub_open_resource): msg_pattern = ".*: %s" % re.escape(repr(uri)) with pytest.raises(DisallowedFileError, match=msg_pattern): loadable.open_local_resource(uri) @@ -1020,7 +1019,7 @@ def test_open_bad_local_resource(self, uri): ) def test_open_bad_local_resource_binary(self, uri): loadable = self.LoadableXBlock(None, scope_ids=Mock()) - with patch('pkg_resources.resource_stream', self.stub_resource_stream): + with patch('xblock.core.Blocklike.open_resource', self.stub_open_resource): msg = ".*: %s" % re.escape(repr(uri.decode('utf-8'))) with pytest.raises(DisallowedFileError, match=msg): loadable.open_local_resource(uri) @@ -1043,7 +1042,7 @@ def test_open_bad_local_resource_binary(self, uri): def test_open_local_resource_with_no_resources_dir(self, uri): unloadable = self.UnloadableXBlock(None, scope_ids=Mock()) - with patch('pkg_resources.resource_stream', self.stub_resource_stream): + with patch('xblock.core.Blocklike.open_resource', self.stub_open_resource): msg = "not configured to serve local resources" with pytest.raises(DisallowedFileError, match=msg): unloadable.open_local_resource(uri) diff --git a/xblock/test/utils/test_resources.py b/xblock/test/utils/test_resources.py index d95b0114d..1ddd59ede 100644 --- a/xblock/test/utils/test_resources.py +++ b/xblock/test/utils/test_resources.py @@ -5,9 +5,9 @@ import gettext import unittest -from unittest.mock import patch, DEFAULT +from unittest.mock import DEFAULT, patch -from pkg_resources import resource_filename +import importlib.resources from xblock.utils.resources import ResourceLoader @@ -136,7 +136,7 @@ class MockI18nService: def __init__(self): locale_dir = 'data/translations' - locale_path = resource_filename(__name__, locale_dir) + locale_path = str(importlib.resources.files(__package__) / locale_dir) domain = 'text' self.mock_translator = gettext.translation( domain, diff --git a/xblock/utils/resources.py b/xblock/utils/resources.py index 1066ffd59..5f1185d00 100644 --- a/xblock/utils/resources.py +++ b/xblock/utils/resources.py @@ -1,13 +1,12 @@ """ Helper class (ResourceLoader) for loading resources used by an XBlock """ - import os import sys import warnings -import pkg_resources -from django.template import Context, Template, Engine +import importlib.resources +from django.template import Context, Engine, Template from django.template.backends.django import get_installed_libraries from mako.lookup import TemplateLookup as MakoTemplateLookup from mako.template import Template as MakoTemplate @@ -22,8 +21,8 @@ def load_unicode(self, resource_path): """ Gets the content of a resource """ - resource_content = pkg_resources.resource_string(self.module_name, resource_path) - return resource_content.decode('utf-8') + package_name = importlib.import_module(self.module_name).__package__ + return importlib.resources.files(package_name).joinpath(resource_path).read_text() def render_django_template(self, template_path, context=None, i18n_service=None): """ @@ -57,7 +56,9 @@ def render_mako_template(self, template_path, context=None): ) context = context or {} template_str = self.load_unicode(template_path) - lookup = MakoTemplateLookup(directories=[pkg_resources.resource_filename(self.module_name, '')]) + directory = str(importlib.resources.as_file( + importlib.resources.files(sys.modules[self.module_name].__package__))) + lookup = MakoTemplateLookup(directories=[directory]) template = MakoTemplate(template_str, lookup=lookup) return template.render(**context) From 8fadc8fae515d4b40ff4c3baeefe7836b3e30c9b Mon Sep 17 00:00:00 2001 From: Muhammad Farhan Khan Date: Thu, 30 May 2024 11:50:14 +0500 Subject: [PATCH 2/6] feat!: drop support for python 3.8 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/pypi-publish.yml | 2 +- .readthedocs.yaml | 2 +- CHANGELOG.rst | 7 +++++++ README.rst | 2 +- docs/xblock-tutorial/getting_started/prereqs.rst | 10 +++++----- setup.py | 1 - tox.ini | 4 ++-- xblock/__init__.py | 2 +- 9 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 833b7fea7..3f66b739f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.8', '3.11', '3.12'] + python-version: ['3.11', '3.12'] toxenv: [quality, django42] steps: @@ -34,7 +34,7 @@ jobs: run: tox -e ${{ matrix.toxenv }} - name: Run Coverage - if: matrix.python-version == '3.8' && matrix.toxenv == 'django42' + if: matrix.python-version == '3.11' && matrix.toxenv == 'django42' uses: codecov/codecov-action@v4 with: flags: unittests diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 1388dea83..6ed7ebfab 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -15,7 +15,7 @@ jobs: - name: setup python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.11 - name: Install pip run: pip install wheel setuptools diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 1d5323576..d52e4ea2b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -19,7 +19,7 @@ formats: build: os: "ubuntu-22.04" tools: - python: "3.8" + python: "3.11" # Optionally set the version of Python and requirements required to build your docs python: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a2fa900bb..6626a4786 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,13 @@ Change history for XBlock Unreleased ---------- +5.0.0 - 2024-05-30 +------------------ + +* dropped python 3.8 support +* transitioned from deprecated pkg_resources lib to importlib.resources + + 4.1.0 - 2024-05-16 ------------------ diff --git a/README.rst b/README.rst index 82369656f..d4d40ae76 100644 --- a/README.rst +++ b/README.rst @@ -45,7 +45,7 @@ One Time Setup cd XBlock # Set up a virtualenv using virtualenvwrapper with the same name as the repo and activate it - mkvirtualenv -p python3.8 XBlock + mkvirtualenv -p python3.11 XBlock Every time you develop something in this repo --------------------------------------------- diff --git a/docs/xblock-tutorial/getting_started/prereqs.rst b/docs/xblock-tutorial/getting_started/prereqs.rst index 9a6c8dfd7..a91510cdd 100644 --- a/docs/xblock-tutorial/getting_started/prereqs.rst +++ b/docs/xblock-tutorial/getting_started/prereqs.rst @@ -11,12 +11,12 @@ To build an XBlock, you must have the following tools on your computer. :depth: 1 -********** -Python 3.8 -********** +*********** +Python 3.11 +*********** To run the a virtual environment and the XBlock SDK, and to build an XBlock, -you must have Python 3.8 installed on your computer. +you must have Python 3.1 installed on your computer. `Download Python`_ for your operating system and follow the installation instructions. @@ -48,7 +48,7 @@ applications you might need. The instructions and examples in this tutorial use `VirtualEnv`_ and `VirtualEnvWrapper`_ to build XBlocks. You can also use `PyEnv`_. -After you have installed Python 3.8, follow the `VirtualEnv Installation`_ +After you have installed Python 3.11, follow the `VirtualEnv Installation`_ instructions. For information on creating the virtual environment for your XBlock, see diff --git a/setup.py b/setup.py index 65340ad43..42f363d5f 100755 --- a/setup.py +++ b/setup.py @@ -73,7 +73,6 @@ def get_version(*file_paths): 'License :: OSI Approved :: Apache Software License', 'Natural Language :: English', "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] diff --git a/tox.ini b/tox.ini index d597966c4..4f9001094 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{38,311,312}-django{42}, quality, docs +envlist = py{311,312}-django{42}, quality, docs [pytest] DJANGO_SETTINGS_MODULE = xblock.test.settings @@ -22,7 +22,7 @@ allowlist_externals = [testenv:docs] basepython = - python3.8 + python3.11 changedir = {toxinidir}/docs deps = diff --git a/xblock/__init__.py b/xblock/__init__.py index 6ac9bd16f..9ba6b90d0 100644 --- a/xblock/__init__.py +++ b/xblock/__init__.py @@ -2,4 +2,4 @@ XBlock Courseware Components """ -__version__ = '4.1.1' +__version__ = '5.0.0' From e7a37c44cc1bd6b50d63abadc00f8ddfbadb96e0 Mon Sep 17 00:00:00 2001 From: Muhammad Farhan Khan Date: Thu, 20 Jun 2024 13:21:37 +0500 Subject: [PATCH 3/6] chore: Add change requests --- docs/xblock-tutorial/getting_started/prereqs.rst | 2 +- xblock/core.py | 4 ++-- xblock/test/test_core.py | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/xblock-tutorial/getting_started/prereqs.rst b/docs/xblock-tutorial/getting_started/prereqs.rst index a91510cdd..01a564563 100644 --- a/docs/xblock-tutorial/getting_started/prereqs.rst +++ b/docs/xblock-tutorial/getting_started/prereqs.rst @@ -16,7 +16,7 @@ Python 3.11 *********** To run the a virtual environment and the XBlock SDK, and to build an XBlock, -you must have Python 3.1 installed on your computer. +you must have Python 3.11 installed on your computer. `Download Python`_ for your operating system and follow the installation instructions. diff --git a/xblock/core.py b/xblock/core.py index 578d8ad60..f2f06ac85 100644 --- a/xblock/core.py +++ b/xblock/core.py @@ -157,10 +157,10 @@ def open_local_resource(cls, uri): if "/." in uri: raise DisallowedFileError("Only safe file names are allowed: %r" % uri) - return cls.open_resource(uri) + return cls._open_resource(uri) @classmethod - def open_resource(cls, uri): + def _open_resource(cls, uri): return importlib.resources.files(inspect.getmodule(cls).__package__).joinpath( os.path.join(cls.resources_dir, uri) ).open('rb') diff --git a/xblock/test/test_core.py b/xblock/test/test_core.py index 4abaa241a..8a888a210 100644 --- a/xblock/test/test_core.py +++ b/xblock/test/test_core.py @@ -962,7 +962,7 @@ class UnloadableXBlock(XBlock): resources_dir = None def stub_open_resource(self, uri): - """Act like xblock.core.Blocklike.open_resource, for testing.""" + """Act like xblock.core.Blocklike._open_resource, for testing.""" return "!" + uri + "!" @ddt.data( @@ -975,7 +975,7 @@ def stub_open_resource(self, uri): ) def test_open_good_local_resource(self, uri): loadable = self.LoadableXBlock(None, scope_ids=Mock()) - with patch('xblock.core.Blocklike.open_resource', self.stub_open_resource): + with patch('xblock.core.Blocklike._open_resource', self.stub_open_resource): assert loadable.open_local_resource(uri) == "!" + uri + "!" assert loadable.open_local_resource(uri.encode('utf-8')) == "!" + uri + "!" @@ -989,7 +989,7 @@ def test_open_good_local_resource(self, uri): ) def test_open_good_local_resource_binary(self, uri): loadable = self.LoadableXBlock(None, scope_ids=Mock()) - with patch('xblock.core.Blocklike.open_resource', self.stub_open_resource): + with patch('xblock.core.Blocklike._open_resource', self.stub_open_resource): assert loadable.open_local_resource(uri) == "!" + uri.decode('utf-8') + "!" @ddt.data( @@ -1003,7 +1003,7 @@ def test_open_good_local_resource_binary(self, uri): ) def test_open_bad_local_resource(self, uri): loadable = self.LoadableXBlock(None, scope_ids=Mock()) - with patch('xblock.core.Blocklike.open_resource', self.stub_open_resource): + with patch('xblock.core.Blocklike._open_resource', self.stub_open_resource): msg_pattern = ".*: %s" % re.escape(repr(uri)) with pytest.raises(DisallowedFileError, match=msg_pattern): loadable.open_local_resource(uri) @@ -1019,7 +1019,7 @@ def test_open_bad_local_resource(self, uri): ) def test_open_bad_local_resource_binary(self, uri): loadable = self.LoadableXBlock(None, scope_ids=Mock()) - with patch('xblock.core.Blocklike.open_resource', self.stub_open_resource): + with patch('xblock.core.Blocklike._open_resource', self.stub_open_resource): msg = ".*: %s" % re.escape(repr(uri.decode('utf-8'))) with pytest.raises(DisallowedFileError, match=msg): loadable.open_local_resource(uri) @@ -1042,7 +1042,7 @@ def test_open_bad_local_resource_binary(self, uri): def test_open_local_resource_with_no_resources_dir(self, uri): unloadable = self.UnloadableXBlock(None, scope_ids=Mock()) - with patch('xblock.core.Blocklike.open_resource', self.stub_open_resource): + with patch('xblock.core.Blocklike._open_resource', self.stub_open_resource): msg = "not configured to serve local resources" with pytest.raises(DisallowedFileError, match=msg): unloadable.open_local_resource(uri) From 49742c8d9af73b6227a263d7a938cdc294a1a973 Mon Sep 17 00:00:00 2001 From: Muhammad Farhan Khan Date: Mon, 24 Jun 2024 17:35:46 +0500 Subject: [PATCH 4/6] fix: Address PR notified exceptions --- xblock/utils/resources.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/xblock/utils/resources.py b/xblock/utils/resources.py index 5f1185d00..b870e2bd2 100644 --- a/xblock/utils/resources.py +++ b/xblock/utils/resources.py @@ -22,7 +22,7 @@ def load_unicode(self, resource_path): Gets the content of a resource """ package_name = importlib.import_module(self.module_name).__package__ - return importlib.resources.files(package_name).joinpath(resource_path).read_text() + return importlib.resources.files(package_name).joinpath(resource_path.lstrip('/')).read_text() def render_django_template(self, template_path, context=None, i18n_service=None): """ @@ -56,8 +56,7 @@ def render_mako_template(self, template_path, context=None): ) context = context or {} template_str = self.load_unicode(template_path) - directory = str(importlib.resources.as_file( - importlib.resources.files(sys.modules[self.module_name].__package__))) + directory = os.path.dirname(os.path.realpath(sys.modules[self.module_name].__file__)) lookup = MakoTemplateLookup(directories=[directory]) template = MakoTemplate(template_str, lookup=lookup) return template.render(**context) From 036ae24e8fa453fa18167bc2e4d536766f21f887 Mon Sep 17 00:00:00 2001 From: Muhammad Farhan Khan Date: Fri, 19 Jul 2024 20:35:51 +0500 Subject: [PATCH 5/6] fix: Address PR request changes --- xblock/core.py | 9 ++++++--- xblock/plugin.py | 6 +----- xblock/utils/resources.py | 7 +++++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/xblock/core.py b/xblock/core.py index f2f06ac85..4dc42bce8 100644 --- a/xblock/core.py +++ b/xblock/core.py @@ -6,7 +6,6 @@ import inspect import json import logging -import os import warnings from collections import OrderedDict, defaultdict @@ -161,8 +160,12 @@ def open_local_resource(cls, uri): @classmethod def _open_resource(cls, uri): - return importlib.resources.files(inspect.getmodule(cls).__package__).joinpath( - os.path.join(cls.resources_dir, uri) + return importlib.resources.files( + inspect.getmodule(cls).__package__ + ).joinpath( + cls.resources_dir + ).joinpath( + uri ).open('rb') @classmethod diff --git a/xblock/plugin.py b/xblock/plugin.py index bc10c3347..6b5888d0c 100644 --- a/xblock/plugin.py +++ b/xblock/plugin.py @@ -100,11 +100,7 @@ def select(identifier, all_entry_points): if select is None: select = default_select - all_entry_points = [ - entry_point - for entry_point in importlib.metadata.entry_points(group=cls.entry_point) - if entry_point.name == identifier - ] + all_entry_points = importlib.metadata.entry_points(group=cls.entry_point, name=identifier) for extra_identifier, extra_entry_point in iter(cls.extra_entry_points): if identifier == extra_identifier: all_entry_points.append(extra_entry_point) diff --git a/xblock/utils/resources.py b/xblock/utils/resources.py index b870e2bd2..778007cd1 100644 --- a/xblock/utils/resources.py +++ b/xblock/utils/resources.py @@ -22,7 +22,8 @@ def load_unicode(self, resource_path): Gets the content of a resource """ package_name = importlib.import_module(self.module_name).__package__ - return importlib.resources.files(package_name).joinpath(resource_path.lstrip('/')).read_text() + # Strip leading slash to avoid importlib exception with absolute paths + return importlib.resources.files(package_name).joinpath(resource_path.lstrip('/')).read_text(encoding="utf-8") def render_django_template(self, template_path, context=None, i18n_service=None): """ @@ -56,7 +57,9 @@ def render_mako_template(self, template_path, context=None): ) context = context or {} template_str = self.load_unicode(template_path) - directory = os.path.dirname(os.path.realpath(sys.modules[self.module_name].__file__)) + + package_name = importlib.import_module(self.module_name).__package__ + directory = str(importlib.resources.files(package_name)) lookup = MakoTemplateLookup(directories=[directory]) template = MakoTemplate(template_str, lookup=lookup) return template.render(**context) From 8885b9410c959eae7d5566fac2aa752ac31e9878 Mon Sep 17 00:00:00 2001 From: Muhammad Farhan Khan Date: Sun, 21 Jul 2024 13:50:55 +0500 Subject: [PATCH 6/6] fix: address PR change request --- xblock/plugin.py | 2 +- xblock/utils/resources.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/xblock/plugin.py b/xblock/plugin.py index 6b5888d0c..3f1574c4b 100644 --- a/xblock/plugin.py +++ b/xblock/plugin.py @@ -100,7 +100,7 @@ def select(identifier, all_entry_points): if select is None: select = default_select - all_entry_points = importlib.metadata.entry_points(group=cls.entry_point, name=identifier) + all_entry_points = list(importlib.metadata.entry_points(group=cls.entry_point, name=identifier)) for extra_identifier, extra_entry_point in iter(cls.extra_entry_points): if identifier == extra_identifier: all_entry_points.append(extra_entry_point) diff --git a/xblock/utils/resources.py b/xblock/utils/resources.py index 778007cd1..f3f2ac69f 100644 --- a/xblock/utils/resources.py +++ b/xblock/utils/resources.py @@ -22,7 +22,11 @@ def load_unicode(self, resource_path): Gets the content of a resource """ package_name = importlib.import_module(self.module_name).__package__ - # Strip leading slash to avoid importlib exception with absolute paths + # TODO: Add encoding on other places as well + # resource_path should be a relative path, but historically some callers passed it in + # with a leading slash, which pkg_resources tolerated and ignored. importlib is less + # forgiving, so in order to maintain backwards compatibility, we must strip off the + # leading slash is there is one to ensure we actually have a relative path. return importlib.resources.files(package_name).joinpath(resource_path.lstrip('/')).read_text(encoding="utf-8") def render_django_template(self, template_path, context=None, i18n_service=None):