diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 0848bf52..7fc3e946 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -10,10 +10,8 @@ jobs: strategy: matrix: python-version: - - '3.8' - '3.11' pip-version: - - 22.0.4 - 23.0.1 - 23.2.1 include: @@ -51,10 +49,10 @@ jobs: uses: actions/checkout@v4 - name: Download artifacts uses: actions/download-artifact@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.11 - name: Install dependencies run: | pip install coverage diff --git a/Changelog.md b/Changelog.md index 762c4e0f..9a9087e5 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,8 @@ +Unreleased +------------------------- +* Drop support for Python 3.8 and `XBlock<2` (and, as a consequence, + any Open edX releases prior to Redwood). + Version 7.13.0 (2024-09-27) ------------------------- * [Enhancement] Enable `show_in_read_only_mode` XBlock attribute diff --git a/README.md b/README.md index da01d326..b234fd85 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ repository, you must select the appropriate one: | Open edX release | Tutor version | XBlock version | XBlock branch | |------------------|---------------|----------------|---------------| | Maple | `>=13.2, <14` | `>=6.0, <7.0` | `stable-6.0` | -| Nutmeg | `>=14.0, <15` | `>=7.0` | `master` | -| Olive | `>=15.0, <16` | `>=7.5` | `master` | -| Palm | `>=16.0, <17` | `>=7.5` | `master` | -| Quince | `>=17.0, <18` | `>=7.9` | `master` | -| Redwood | `>=18.0, <19` | `>=7.12` | `master` | +| Nutmeg | `>=14.0, <15` | `>=7.0, <8.0` | `stable-7` | +| Olive | `>=15.0, <16` | `>=7.5, <8.0` | `stable-7` | +| Palm | `>=16.0, <17` | `>=7.5, <8.0` | `stable-7` | +| Quince | `>=17.0, <18` | `>=7.9, <8.0` | `stable-7` | +| Redwood | `>=18.0, <19` | `>=8.0` | `master` | Instructions for deploying this XBlock with Tutor can be found below, in the [Deployment with Tutor](#deployment-with-tutor) diff --git a/hastexo/hastexo.py b/hastexo/hastexo.py index be408673..6b1c50c1 100644 --- a/hastexo/hastexo.py +++ b/hastexo/hastexo.py @@ -8,24 +8,14 @@ from xblock.core import XBlock, XML_NAMESPACES from xblock.fields import Scope, Float, String, Dict, List, Integer, Boolean -try: # XBlock 2+ - from web_fragments.fragment import Fragment - from xblock.utils.resources import ResourceLoader - from xblock.utils.settings import XBlockWithSettingsMixin - from xblock.utils.studio_editable import ( - NestedXBlockSpec, - StudioContainerWithNestedXBlocksMixin, - StudioEditableXBlockMixin - ) -except ImportError: # Compatibility with XBlock<2 - from xblock.fragment import Fragment - from xblockutils.resources import ResourceLoader - from xblockutils.settings import XBlockWithSettingsMixin - from xblockutils.studio_editable import ( - NestedXBlockSpec, - StudioContainerWithNestedXBlocksMixin, - StudioEditableXBlockMixin, - ) +from web_fragments.fragment import Fragment +from xblock.utils.resources import ResourceLoader +from xblock.utils.settings import XBlockWithSettingsMixin +from xblock.utils.studio_editable import ( + NestedXBlockSpec, + StudioContainerWithNestedXBlocksMixin, + StudioEditableXBlockMixin +) from xblock.scorable import ScorableXBlockMixin, Score @@ -318,21 +308,13 @@ def parse_attributes(tag, node, block): block.providers.append(provider) @classmethod - def parse_xml(cls, node, runtime, keys, id_generator=None): + def parse_xml(cls, node, runtime, keys): """ Use `node` to construct a new block. """ block = runtime.construct_xblock_from_class(cls, keys) - # Prior to XBlock 2.0, id_generator is passed in. - # Since XBlock 2.0, we grab it from the runtime. - # - # TODO: Once we decide to drop support for versions prior to - # XBlock 2 (i.e. Open edX releases before Redwood), we can - # drop id_generator from the method signature, and always rely - # on runtime.id_generator. - if not id_generator: - id_generator = runtime.id_generator + id_generator = runtime.id_generator if 'filename' in node.attrib: # Read xml content from file. @@ -374,15 +356,7 @@ def parse_xml(cls, node, runtime, keys, id_generator=None): child.tag)) # Import nested blocks for child in node: - # Prior to XBlock 2.0, id_generator needs to be passed here. - # - # TODO: Once we decide to drop support for versions prior to - # XBlock 2 (i.e. Open edX releases before Redwood), we can - # drop the try/except block and passing the id_generator here. - try: - block.runtime.add_node_as_child(block, child) - except TypeError: - block.runtime.add_node_as_child(block, child, id_generator) + block.runtime.add_node_as_child(block, child) else: for child in node: @@ -400,18 +374,7 @@ def parse_xml(cls, node, runtime, keys, id_generator=None): cls.parse_attributes(child.tag, child, block) else: # Import nested blocks - - # Prior to XBlock 2.0, id_generator needs to be passed. - # - # TODO: Once we decide to drop support for versions prior - # to XBlock 2 (i.e. Open edX releases before Redwood), - # we can drop the try/except block here and stop passing - # the id_generator. - try: - block.runtime.add_node_as_child(block, child) - except TypeError: - block.runtime.add_node_as_child( - block, child, id_generator) + block.runtime.add_node_as_child(block, child) # Attributes become fields. for name, value in list(node.items()): # lxml has no iteritems @@ -743,19 +706,7 @@ def student_view(self, context=None): for child_id in self.children: child = self.runtime.get_block(child_id) child_fragment = child.render("student_view", context) - - # Prior to XBlock 2.0, Fragment is imported from XBlock - # and we the `add_frag_resources` method. - # - # TODO: Once we decide to drop support for versions prior - # to XBlock 2 (i.e. Open edX releases before Redwood), - # we can drop the try/except block here and use - # `add_fragment_resources` from `web_fragments.Fragment` - try: - frag.add_fragment_resources(child_fragment) - except AttributeError: - frag.add_frag_resources(child_fragment) - + frag.add_fragment_resources(child_fragment) child_content += child_fragment.content # Render the main template diff --git a/requirements/base.txt b/requirements/base.txt index c2741372..a4634955 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -20,7 +20,6 @@ tenacity>=6.2,<8 django<=4.2.14 channels<=4.0.0 daphne<=4.0.0 -twisted<24;python_version<="3.9" # drop this restriction once we drop Python 3.8 and 3.9 support mysqlclient<=2.2.4 # keep in sync with edx-platform jsonfield>=3.1.0,<4 # keep in sync with edx-platform pyguacamole>=0.11 diff --git a/tests/resources/course/hastexo/fake_lab_hook_events_1.xml b/tests/resources/course/hastexo/fake_lab_hook_events_1.xml index fe8c5f3c..a824e44d 100644 --- a/tests/resources/course/hastexo/fake_lab_hook_events_1.xml +++ b/tests/resources/course/hastexo/fake_lab_hook_events_1.xml @@ -1,3 +1,4 @@ + diff --git a/tests/unit/test_hastexo.py b/tests/unit/test_hastexo.py index bd023f6f..17707919 100644 --- a/tests/unit/test_hastexo.py +++ b/tests/unit/test_hastexo.py @@ -1185,24 +1185,25 @@ def test_export_nested_xblock(self): def test_parse_xblock_from_separate_file(self): block_type = 'hastexo' + block_id = 'fake_lab_1' node = etree.Element(block_type) - node.set('filename', 'fake_lab_1') + node.set('filename', block_id) self.block.runtime.resources_fs = OSFS('tests/resources/course') def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) scope_ids = ScopeIds('user', block_type, def_id, usage_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_1' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) # run block = self.block.parse_xml( - node, self.block.runtime, scope_ids, id_generator) + node, self.block.runtime, scope_ids) # assertions self.assertIsInstance(block, HastexoXBlock) @@ -1230,15 +1231,15 @@ def test_parse_xblock_no_filename(self): node = etree.Element(block_type) node.set('read_only', 'true') node.set('display_name', 'Fake Lab') + node.append(etree.Comment(text="Fake Comment 123")) self.block.runtime.resources_fs = Mock() - id_generator = Mock() def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) scope_ids = ScopeIds('user', block_type, def_id, usage_id) block = self.block.parse_xml( - node, self.block.runtime, scope_ids, id_generator) + node, self.block.runtime, scope_ids) # assert that since no 'filename' attribute is present, # we don't attempt to open any files. @@ -1261,23 +1262,24 @@ def test_parse_xblock_no_filename(self): def test_parse_xblock_missing_capacity(self): block_type = 'hastexo' + block_id = 'fake_lab_no_provider_capacity' node = etree.Element(block_type) - node.set('filename', 'fake_lab_no_provider_capacity') + node.set('filename', block_id) self.block.runtime.resources_fs = OSFS('tests/resources/course') def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_no_provider_capacity' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) scope_ids = ScopeIds('user', block_type, def_id, usage_id) block = self.block.parse_xml( - node, self.block.runtime, scope_ids, id_generator) + node, self.block.runtime, scope_ids) # assert that a missing capacity will result on it being set to -1 self.assertEqual(len(block.providers), 3) @@ -1287,92 +1289,96 @@ def test_parse_xblock_missing_capacity(self): def test_parse_xblock_missing_provider_name(self): block_type = 'hastexo' + block_id = 'fake_lab_no_provider_name' node = etree.Element(block_type) - node.set('filename', 'fake_lab_no_provider_name') + node.set('filename', block_id) self.block.runtime.resources_fs = OSFS('tests/resources/course') def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_no_provider_name' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) scope_ids = ScopeIds('user', block_type, def_id, usage_id) # assert that missing provider name raises KeyError with self.assertRaises(KeyError): self.block.parse_xml( - node, self.block.runtime, scope_ids, id_generator) + node, self.block.runtime, scope_ids) def test_parse_xblock_missing_port_name(self): block_type = 'hastexo' + block_id = 'fake_lab_no_port_name' node = etree.Element(block_type) - node.set('filename', 'fake_lab_no_port_name') + node.set('filename', block_id) self.block.runtime.resources_fs = OSFS('tests/resources/course') def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_no_port_name' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) scope_ids = ScopeIds('user', block_type, def_id, usage_id) # assert that missing port name raises KeyError with self.assertRaises(KeyError): self.block.parse_xml( - node, self.block.runtime, scope_ids, id_generator) + node, self.block.runtime, scope_ids) def test_parse_xblock_missing_port_number(self): block_type = 'hastexo' + block_id = 'fake_lab_no_port_number' node = etree.Element(block_type) - node.set('filename', 'fake_lab_no_port_number') + node.set('filename', block_id) self.block.runtime.resources_fs = OSFS('tests/resources/course') def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_no_port_number' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) scope_ids = ScopeIds('user', block_type, def_id, usage_id) # assert that missing port number raises KeyError with self.assertRaises(KeyError): self.block.parse_xml( - node, self.block.runtime, scope_ids, id_generator) + node, self.block.runtime, scope_ids) def test_parse_xblock_hook_events_1(self): block_type = 'hastexo' + block_id = 'fake_lab_hook_events_1' node = etree.Element(block_type) - node.set('filename', 'fake_lab_hook_events_1') + node.set('filename', block_id) self.block.runtime.resources_fs = OSFS('tests/resources/course') def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_hook_events_1' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) scope_ids = ScopeIds('user', block_type, def_id, usage_id) block = self.block.parse_xml( - node, self.block.runtime, scope_ids, id_generator) + node, self.block.runtime, scope_ids) # assert that all three definitions will result in being set to True self.assertEqual(block.hook_events['suspend'], True) @@ -1381,23 +1387,24 @@ def test_parse_xblock_hook_events_1(self): def test_parse_xblock_hook_events_2(self): block_type = 'hastexo' + block_id = 'fake_lab_hook_events_2' node = etree.Element(block_type) - node.set('filename', 'fake_lab_hook_events_2') + node.set('filename', block_id) self.block.runtime.resources_fs = OSFS('tests/resources/course') def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_hook_events_2' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) scope_ids = ScopeIds('user', block_type, def_id, usage_id) block = self.block.parse_xml( - node, self.block.runtime, scope_ids, id_generator) + node, self.block.runtime, scope_ids) # assert that 'false' and 'False' will both be set as False, # an undefined attribute ('delete') will default to True @@ -1407,24 +1414,25 @@ def test_parse_xblock_hook_events_2(self): def test_parse_xblock_lab_2(self): block_type = 'hastexo' + block_id = 'fake_lab_2' node = etree.Element(block_type) - node.set('filename', 'fake_lab_2') + node.set('filename', block_id) self.block.runtime.resources_fs = OSFS('tests/resources/course') def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) scope_ids = ScopeIds('user', block_type, def_id, usage_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_2' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) # run block = self.block.parse_xml( - node, self.block.runtime, scope_ids, id_generator) + node, self.block.runtime, scope_ids) # assertions self.assertIsInstance(block, HastexoXBlock) @@ -1449,25 +1457,26 @@ def test_full_round_import_export(self): # import xblock from fake_lab_1.xml # setup block_type = 'hastexo' + block_id = 'fake_lab_1' resources_fs = OSFS('tests/resources/course') self.block.runtime.resources_fs = resources_fs def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) scope_ids = ScopeIds('user', block_type, def_id, usage_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_1' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) # create the hastexo node hastexo_node = etree.Element(block_type) - hastexo_node.set('filename', 'fake_lab_1') + hastexo_node.set('filename', block_id) hastexo_node.set('stack_user_name', 'training') # run the import block = self.block.parse_xml( - hastexo_node, self.block.runtime, scope_ids, id_generator) + hastexo_node, self.block.runtime, scope_ids) # export the xblock to fake_lab.xml # setup @@ -1504,25 +1513,26 @@ def test_full_round_import_export_2(self): # import xblock from fake_lab_2.xml # setup block_type = 'hastexo' + block_id = 'fake_lab_2' resources_fs = OSFS('tests/resources/course') self.block.runtime.resources_fs = resources_fs def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) scope_ids = ScopeIds('user', block_type, def_id, usage_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_2' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) # create the hastexo node hastexo_node = etree.Element(block_type) - hastexo_node.set('filename', 'fake_lab_2') + hastexo_node.set('filename', block_id) hastexo_node.set('stack_user_name', 'training') # run the import block = self.block.parse_xml( - hastexo_node, self.block.runtime, scope_ids, id_generator) + hastexo_node, self.block.runtime, scope_ids) # export the xblock to fake_lab.xml # setup @@ -1584,25 +1594,26 @@ def test_export_import_export(self): # import the exported file from export location block_type = 'hastexo' + block_id = 'fake_lab_1' resources_fs = OSFS(export_location) self.block.runtime.resources_fs = resources_fs def_id = self.block.runtime.id_generator.create_definition(block_type) usage_id = self.block.runtime.id_generator.create_usage(def_id) scope_ids = ScopeIds('user', block_type, def_id, usage_id) - id_generator = Mock() - id_generator.create_definition = Mock() fake_location = Mock() - fake_location.block_id = 'fake_lab_1' - id_generator.create_definition.return_value = fake_location + fake_location.block_id = block_id + self.block.runtime.id_generator = Mock() + self.block.runtime.id_generator.create_definition = Mock( + return_value=fake_location) # create the hastexo node hastexo_node = etree.Element(block_type) - hastexo_node.set('filename', 'fake_lab_1') + hastexo_node.set('filename', block_id) hastexo_node.set('stack_user_name', 'training') # run the import block = self.block.parse_xml( - hastexo_node, self.block.runtime, scope_ids, id_generator) + hastexo_node, self.block.runtime, scope_ids) # export the imported xblock to fake_lab_2.xml block.url_name = 'fake_lab_2' diff --git a/tox.ini b/tox.ini index 814fc52c..55fef082 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,8 @@ [tox] -envlist = flake8,pipdeptree{,-requirements},py38-xblock18-celery5,py{311,312}-xblock{40}-celery5 +envlist = flake8,pipdeptree{,-requirements},py{311,312}-xblock40-celery5 [gh-actions] python = - 3.8: flake8,pipdeptree,pipdeptree-requirements,py38 3.11: flake8,pipdeptree,pipdeptree-requirements,py311 3.12: flake8,pipdeptree,pipdeptree-requirements,py312 @@ -27,7 +26,6 @@ exclude_lines = deps = -rrequirements/setup.txt -rrequirements/test.txt - xblock18: XBlock>=1.8,<1.9 xblock40: XBlock>=4.0,<5 celery5: celery>=5,<6 commands =