From a63390b13967999945ba5e69833e9ca6cb3f92df Mon Sep 17 00:00:00 2001 From: Muhammad Farhan Khan Date: Thu, 25 Jul 2024 11:46:00 +0500 Subject: [PATCH] Drop py3.8 support | Replace pkg_resources lib with importlib.resources (#716) * chore: transitioned from pkg_resources api to importlib-resources api * 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 +- .../getting_started/prereqs.rst | 10 +++++----- setup.py | 1 - tox.ini | 4 ++-- xblock/__init__.py | 2 +- xblock/core.py | 15 ++++++++++++--- xblock/plugin.py | 6 +++--- xblock/test/test_core.py | 17 ++++++++--------- xblock/test/utils/test_resources.py | 6 +++--- xblock/utils/resources.py | 19 +++++++++++++------ 14 files changed, 59 insertions(+), 38 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..01a564563 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.11 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 e679b053b..9ba6b90d0 100644 --- a/xblock/__init__.py +++ b/xblock/__init__.py @@ -2,4 +2,4 @@ XBlock Courseware Components """ -__version__ = '4.1.0' +__version__ = '5.0.0' diff --git a/xblock/core.py b/xblock/core.py index fcea0f597..4dc42bce8 100644 --- a/xblock/core.py +++ b/xblock/core.py @@ -6,11 +6,10 @@ import inspect import json import logging -import os import warnings from collections import OrderedDict, defaultdict -import pkg_resources +import importlib.resources from lxml import etree from webob import Response @@ -157,7 +156,17 @@ 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( + cls.resources_dir + ).joinpath( + uri + ).open('rb') @classmethod def json_handler(cls, func): diff --git a/xblock/plugin.py b/xblock/plugin.py index 42f1ca6ce..3f1574c4b 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,7 @@ 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 = 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) @@ -133,7 +133,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..8a888a210 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..f3f2ac69f 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,13 @@ 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__ + # 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): """ @@ -57,7 +61,10 @@ 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, '')]) + + 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)