From 8f28c1f73a7f15e61635027545b18eed9e405bea Mon Sep 17 00:00:00 2001 From: irfanuddinahmad Date: Tue, 19 May 2026 09:46:37 +0500 Subject: [PATCH 1/5] chore: pin GitHub Actions workflows to full commit SHAs --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish_pypi.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf59da7..0368636 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,9 @@ jobs: toxenv: ['django42', 'django52'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index b4a9bea..c27768b 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -12,9 +12,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: 3.12 From 4dac5d320235c5bcf0e31da4a3290ff5dc47715a Mon Sep 17 00:00:00 2001 From: Irfan Ahmad Date: Tue, 2 Jun 2026 16:56:08 +0500 Subject: [PATCH 2/5] fix: add setuptools to requirements for Python 3.12 compatibility enmerkar 0.7.1 calls `from pkg_resources import get_distribution` in its __init__.py at runtime. pkg_resources ships with setuptools, which is no longer bundled with Python 3.12. Adding setuptools as an explicit requirement ensures it is available in the tox virtualenv. Co-Authored-By: Claude Sonnet 4.6 --- requirements/base.in | 1 + requirements/tox.txt | 3 +++ 2 files changed, 4 insertions(+) diff --git a/requirements/base.in b/requirements/base.in index cb5bffb..470848c 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -6,3 +6,4 @@ django babel>=1.3 enmerkar==0.7.1 +setuptools # enmerkar 0.7.1 uses pkg_resources at runtime; not bundled in Python 3.12 diff --git a/requirements/tox.txt b/requirements/tox.txt index 25cee31..ddb3b04 100644 --- a/requirements/tox.txt +++ b/requirements/tox.txt @@ -109,6 +109,9 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx +setuptools + # via -r requirements/base.in + # (enmerkar 0.7.1 uses pkg_resources at runtime; not bundled in Python 3.12) sqlparse==0.5.3 # via django urllib3==2.5.0 From d1241da0d6d8930622aef246681194b820bfae7a Mon Sep 17 00:00:00 2001 From: Irfan Ahmad Date: Tue, 2 Jun 2026 21:10:52 +0500 Subject: [PATCH 3/5] fix: add conftest.py shim for pkg_resources when setuptools unavailable enmerkar 0.7.1 imports pkg_resources at module load time but does not declare setuptools as a dependency. In some virtualenv configurations, pkg_resources is not importable even when setuptools is installed. This conftest.py creates a minimal importlib.metadata-backed shim so that pytest collection succeeds regardless of the virtualenv setup. Co-Authored-By: Claude Sonnet 4.6 --- conftest.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 conftest.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..d9146b2 --- /dev/null +++ b/conftest.py @@ -0,0 +1,34 @@ +""" +Ensure pkg_resources is importable before any tests run. + +enmerkar 0.7.1 does `from pkg_resources import get_distribution` in its +__init__.py. pkg_resources ships with setuptools, but in some virtualenv +configurations setuptools is installed without pkg_resources being +importable. This shim falls back to importlib.metadata when needed. +""" +import sys + +try: + import pkg_resources # noqa: F401 +except ImportError: + import importlib.metadata + import types + + _mod = types.ModuleType("pkg_resources") + + class _Dist: + def __init__(self, dist): + self._dist = dist + + @property + def version(self): + return self._dist.metadata["Version"] + + def __str__(self): + return self._dist.metadata["Name"] + + def _get_distribution(name): + return _Dist(importlib.metadata.distribution(name)) + + _mod.get_distribution = _get_distribution + sys.modules["pkg_resources"] = _mod From ca2fe09693897dad2cf5e440de46373ee5aba93e Mon Sep 17 00:00:00 2001 From: Irfan Ahmad Date: Wed, 10 Jun 2026 17:36:43 +0500 Subject: [PATCH 4/5] refactor: vendor enmerkar/extract.py, drop enmerkar dep and pkg_resources shim enmerkar 0.7.1 does `from pkg_resources import get_distribution` in its __init__.py, which fails in Python 3.12+ environments where setuptools is not installed. We only use `extract_django` from enmerkar/extract.py, and that module has no pkg_resources dependency. Vendor enmerkar/extract.py directly (as already done for markey) so we use importlib.metadata-compatible code throughout, then drop the enmerkar package dependency, the setuptools pin, and the conftest.py shim. Co-Authored-By: Claude Sonnet 4.6 --- conftest.py | 34 ---- requirements/base.in | 2 - requirements/base.txt | 11 +- requirements/tox.txt | 10 - src/enmerkar_underscore/__init__.py | 2 +- .../vendor/enmerkar_extract.py | 174 ++++++++++++++++++ 6 files changed, 178 insertions(+), 55 deletions(-) delete mode 100644 conftest.py create mode 100644 src/enmerkar_underscore/vendor/enmerkar_extract.py diff --git a/conftest.py b/conftest.py deleted file mode 100644 index d9146b2..0000000 --- a/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Ensure pkg_resources is importable before any tests run. - -enmerkar 0.7.1 does `from pkg_resources import get_distribution` in its -__init__.py. pkg_resources ships with setuptools, but in some virtualenv -configurations setuptools is installed without pkg_resources being -importable. This shim falls back to importlib.metadata when needed. -""" -import sys - -try: - import pkg_resources # noqa: F401 -except ImportError: - import importlib.metadata - import types - - _mod = types.ModuleType("pkg_resources") - - class _Dist: - def __init__(self, dist): - self._dist = dist - - @property - def version(self): - return self._dist.metadata["Version"] - - def __str__(self): - return self._dist.metadata["Name"] - - def _get_distribution(name): - return _Dist(importlib.metadata.distribution(name)) - - _mod.get_distribution = _get_distribution - sys.modules["pkg_resources"] = _mod diff --git a/requirements/base.in b/requirements/base.in index 470848c..10d799b 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -5,5 +5,3 @@ django babel>=1.3 -enmerkar==0.7.1 -setuptools # enmerkar 0.7.1 uses pkg_resources at runtime; not bundled in Python 3.12 diff --git a/requirements/base.txt b/requirements/base.txt index b21f985..521e4ad 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,21 +1,16 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# make upgrade +# pip-compile --allow-unsafe --output-file=requirements/base.txt requirements/base.in # asgiref==3.10.0 # via django babel==2.17.0 - # via - # -r requirements/base.in - # enmerkar + # via -r requirements/base.in django==4.2.25 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in - # enmerkar -enmerkar==0.7.1 - # via -r requirements/base.in sqlparse==0.5.3 # via django diff --git a/requirements/tox.txt b/requirements/tox.txt index ddb3b04..e11078a 100644 --- a/requirements/tox.txt +++ b/requirements/tox.txt @@ -11,7 +11,6 @@ asgiref==3.10.0 babel==2.17.0 # via # -r requirements/base.in - # enmerkar # sphinx certifi==2025.10.5 # via requests @@ -22,14 +21,8 @@ coverage[toml]==7.11.0 # -r requirements/test.in # pytest-cov # python-coveralls - # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt - # -r requirements/base.in - # enmerkar docutils==0.21.2 # via sphinx -enmerkar==0.7.1 - # via -r requirements/base.in execnet==2.1.1 # via pytest-cache flake8==7.3.0 @@ -109,9 +102,6 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -setuptools - # via -r requirements/base.in - # (enmerkar 0.7.1 uses pkg_resources at runtime; not bundled in Python 3.12) sqlparse==0.5.3 # via django urllib3==2.5.0 diff --git a/src/enmerkar_underscore/__init__.py b/src/enmerkar_underscore/__init__.py index 682cc6a..4183ead 100644 --- a/src/enmerkar_underscore/__init__.py +++ b/src/enmerkar_underscore/__init__.py @@ -8,7 +8,7 @@ from django.template.base import TOKEN_TEXT from django.utils.encoding import force_str -from enmerkar.extract import extract_django +from .vendor.enmerkar_extract import extract_django from .vendor.markey import underscore from .vendor.markey.machine import parse_arguments, tokenize diff --git a/src/enmerkar_underscore/vendor/enmerkar_extract.py b/src/enmerkar_underscore/vendor/enmerkar_extract.py new file mode 100644 index 0000000..b19d6fb --- /dev/null +++ b/src/enmerkar_underscore/vendor/enmerkar_extract.py @@ -0,0 +1,174 @@ +# Vendored from https://github.com/Zegocover/enmerkar (enmerkar 0.7.1) +# enmerkar/__init__.py uses pkg_resources (a setuptools artifact not guaranteed +# to be present in Python 3.12+ environments). We only need extract_django, and +# enmerkar/extract.py has no such dependency, so we vendor it here directly. +from django.template.base import Lexer +from django.template.base import TokenType + +from django.utils.translation import trim_whitespace +from django.utils.encoding import smart_str + +from django.utils.translation.template import ( + inline_re, block_re, endblock_re, plural_re, constant_re) + + +TOKEN_TEXT = TokenType.TEXT +TOKEN_VAR = TokenType.VAR +TOKEN_BLOCK = TokenType.BLOCK + + +def join_tokens(tokens, trim=False): + message = ''.join(tokens) + if trim: + message = trim_whitespace(message) + return message + + +def strip_quotes(s): + if (s[0] == s[-1]) and s.startswith(("'", '"')): + return s[1:-1] + return s + + +def extract_django(fileobj, keywords, comment_tags, options): + """Extract messages from Django template files. + + :param fileobj: the file-like object the messages should be extracted from + :param keywords: a list of keywords (i.e. function names) that should + be recognized as translation functions + :param comment_tags: a list of translator tags to search for and + include in the results + :param options: a dictionary of additional options (optional) + :return: an iterator over ``(lineno, funcname, message, comments)`` + tuples + :rtype: ``iterator`` + """ + intrans = False + inplural = False + trimmed = False + message_context = None + singular = [] + plural = [] + lineno = 1 + + encoding = options.get('encoding', 'utf8') + text = fileobj.read().decode(encoding) + + try: + text_lexer = Lexer(text) + except TypeError: + # Django 1.9 changed the way we invoke Lexer; older versions + # require two parameters. + text_lexer = Lexer(text, None) + + for t in text_lexer.tokenize(): + lineno += t.contents.count('\n') + if intrans: + if t.token_type == TOKEN_BLOCK: + endbmatch = endblock_re.match(t.contents) + pluralmatch = plural_re.match(t.contents) + if endbmatch: + if inplural: + if message_context: + yield ( + lineno, + 'npgettext', + [smart_str(message_context), + smart_str(join_tokens(singular, trimmed)), + smart_str(join_tokens(plural, trimmed))], + [], + ) + else: + yield ( + lineno, + 'ngettext', + (smart_str(join_tokens(singular, trimmed)), + smart_str(join_tokens(plural, trimmed))), + []) + else: + if message_context: + yield ( + lineno, + 'pgettext', + [smart_str(message_context), + smart_str(join_tokens(singular, trimmed))], + [], + ) + else: + yield ( + lineno, + None, + smart_str(join_tokens(singular, trimmed)), + []) + + intrans = False + inplural = False + message_context = None + singular = [] + plural = [] + elif pluralmatch: + inplural = True + else: + raise SyntaxError('Translation blocks must not include ' + 'other block tags: %s' % t.contents) + elif t.token_type == TOKEN_VAR: + if inplural: + plural.append('%%(%s)s' % t.contents) + else: + singular.append('%%(%s)s' % t.contents) + elif t.token_type == TOKEN_TEXT: + if inplural: + plural.append(t.contents) + else: + singular.append(t.contents) + else: + if t.token_type == TOKEN_BLOCK: + imatch = inline_re.match(t.contents) + bmatch = block_re.match(t.contents) + cmatches = constant_re.findall(t.contents) + if imatch: + g = imatch.group(1) + g = strip_quotes(g) + message_context = imatch.group(3) + if message_context: + # strip quotes + message_context = message_context[1:-1] + yield ( + lineno, + 'pgettext', + [smart_str(message_context), smart_str(g)], + [], + ) + message_context = None + else: + yield lineno, None, smart_str(g), [] + elif bmatch: + if bmatch.group(2): + message_context = bmatch.group(2)[1:-1] + for fmatch in constant_re.findall(t.contents): + stripped_fmatch = strip_quotes(fmatch) + yield lineno, None, smart_str(stripped_fmatch), [] + intrans = True + inplural = False + trimmed = 'trimmed' in t.split_contents() + singular = [] + plural = [] + elif cmatches: + for cmatch in cmatches: + stripped_cmatch = strip_quotes(cmatch) + yield lineno, None, smart_str(stripped_cmatch), [] + elif t.token_type == TOKEN_VAR: + parts = t.contents.split('|') + cmatch = constant_re.match(parts[0]) + if cmatch: + stripped_cmatch = strip_quotes(cmatch.group(1)) + yield lineno, None, smart_str(stripped_cmatch), [] + for p in parts[1:]: + if p.find(':_(') >= 0: + p1 = p.split(':', 1)[1] + if p1[0] == '_': + p1 = p1[1:] + if p1[0] == '(': + p1 = p1.strip('()') + p1 = strip_quotes(p1) + yield lineno, None, smart_str(p1), [] From 2dc2e2cd3245cf2ed4919528b204a48f1bf479c2 Mon Sep 17 00:00:00 2001 From: Irfan Ahmad Date: Thu, 25 Jun 2026 16:58:12 +0500 Subject: [PATCH 5/5] chore: move vendored enmerkar into its own subfolder with LICENSE Add upstream BSD-3-Clause LICENSE (from Zegocover/enmerkar COPYING) and move enmerkar_extract.py into vendor/enmerkar/ per code review feedback. Co-Authored-By: Claude Sonnet 4.6 --- src/enmerkar_underscore/__init__.py | 2 +- .../vendor/enmerkar/LICENSE | 30 +++++++++++++++++++ .../vendor/{ => enmerkar}/enmerkar_extract.py | 0 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/enmerkar_underscore/vendor/enmerkar/LICENSE rename src/enmerkar_underscore/vendor/{ => enmerkar}/enmerkar_extract.py (100%) diff --git a/src/enmerkar_underscore/__init__.py b/src/enmerkar_underscore/__init__.py index 4183ead..c5c88b2 100644 --- a/src/enmerkar_underscore/__init__.py +++ b/src/enmerkar_underscore/__init__.py @@ -8,7 +8,7 @@ from django.template.base import TOKEN_TEXT from django.utils.encoding import force_str -from .vendor.enmerkar_extract import extract_django +from .vendor.enmerkar.enmerkar_extract import extract_django from .vendor.markey import underscore from .vendor.markey.machine import parse_arguments, tokenize diff --git a/src/enmerkar_underscore/vendor/enmerkar/LICENSE b/src/enmerkar_underscore/vendor/enmerkar/LICENSE new file mode 100644 index 0000000..65c246e --- /dev/null +++ b/src/enmerkar_underscore/vendor/enmerkar/LICENSE @@ -0,0 +1,30 @@ +Copyright (C) 2013-2014 django-babel Team +Copyright (C) 2019 Extracover Holdings Limited + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. The name of the author may not be used to endorse or promote + products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/enmerkar_underscore/vendor/enmerkar_extract.py b/src/enmerkar_underscore/vendor/enmerkar/enmerkar_extract.py similarity index 100% rename from src/enmerkar_underscore/vendor/enmerkar_extract.py rename to src/enmerkar_underscore/vendor/enmerkar/enmerkar_extract.py