From 487de1b91b6f76bde15175285022a37aff875c99 Mon Sep 17 00:00:00 2001 From: rahul-apphelix Date: Tue, 6 Oct 2026 20:59:52 +0530 Subject: [PATCH 1/5] feat: add management command context manager filter --- manage.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/manage.py b/manage.py index e852b25adce4..7b74d4e31748 100755 --- a/manage.py +++ b/manage.py @@ -23,6 +23,35 @@ import os import sys from argparse import ArgumentParser +from contextlib import nullcontext + +from openedx_filters.tooling import OpenEdxPublicFilter + + +class ManagementCommandContextmanagerRequested(OpenEdxPublicFilter): + """ + Filter triggered before a management command is executed. + + Pipeline steps may provide a context manager to wrap command execution. + """ + + filter_type = 'org.openedx.platform.management.command.contextmanager.requested.v1' + + @classmethod + def run_filter(cls, command_contextmanager, command_name, service_variant): + """ + Run the management command context manager pipeline. + """ + data = super().run_pipeline( + command_contextmanager=command_contextmanager, + command_name=command_name, + service_variant=service_variant, + ) + return ( + data.get('command_contextmanager', command_contextmanager), + data.get('command_name', command_name), + data.get('service_variant', service_variant), + ) def parse_args(): @@ -90,4 +119,17 @@ def parse_args(): django_args.append('--help') from django.core.management import execute_from_command_line - execute_from_command_line([sys.argv[0]] + django_args) + + # First unconsumed argument is treated as the Django command name (e.g. 'migrate', 'runserver'). + # Falls back to 'no-argument' if django_args is empty. + command_name = django_args[0] if django_args else 'no-argument' + service_variant = edx_args.settings_base.split('/', maxsplit=1)[0] + + command_contextmanager, _, _ = ManagementCommandContextmanagerRequested.run_filter( + command_contextmanager=nullcontext(), + command_name=command_name, + service_variant=service_variant, + ) + + with command_contextmanager: + execute_from_command_line([sys.argv[0]] + django_args) From 7991a98ac175087197a0ee969ee9ddc35bfc5ebd Mon Sep 17 00:00:00 2001 From: rahul-apphelix Date: Thu, 8 Oct 2026 14:56:38 +0530 Subject: [PATCH 2/5] fix: align management command filter type with management subdomain --- manage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manage.py b/manage.py index 7b74d4e31748..d21ad7a42b03 100755 --- a/manage.py +++ b/manage.py @@ -35,7 +35,7 @@ class ManagementCommandContextmanagerRequested(OpenEdxPublicFilter): Pipeline steps may provide a context manager to wrap command execution. """ - filter_type = 'org.openedx.platform.management.command.contextmanager.requested.v1' + filter_type = 'org.openedx.management.command.contextmanager.requested.v1' @classmethod def run_filter(cls, command_contextmanager, command_name, service_variant): From c5843deab799b64258eea9233af50bd83ba75e31 Mon Sep 17 00:00:00 2001 From: rahul-apphelix Date: Mon, 19 Oct 2026 14:41:13 +0530 Subject: [PATCH 3/5] feat: use public management command filter from openedx-filters --- manage.py | 28 +--------------------------- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- 3 files changed, 3 insertions(+), 29 deletions(-) diff --git a/manage.py b/manage.py index d21ad7a42b03..fa3d3a9d4944 100755 --- a/manage.py +++ b/manage.py @@ -25,33 +25,7 @@ from argparse import ArgumentParser from contextlib import nullcontext -from openedx_filters.tooling import OpenEdxPublicFilter - - -class ManagementCommandContextmanagerRequested(OpenEdxPublicFilter): - """ - Filter triggered before a management command is executed. - - Pipeline steps may provide a context manager to wrap command execution. - """ - - filter_type = 'org.openedx.management.command.contextmanager.requested.v1' - - @classmethod - def run_filter(cls, command_contextmanager, command_name, service_variant): - """ - Run the management command context manager pipeline. - """ - data = super().run_pipeline( - command_contextmanager=command_contextmanager, - command_name=command_name, - service_variant=service_variant, - ) - return ( - data.get('command_contextmanager', command_contextmanager), - data.get('command_name', command_name), - data.get('service_variant', service_variant), - ) +from openedx_filters.management.filters import ManagementCommandContextmanagerRequested def parse_args(): diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index c7120f2e0aeb..412fe90aba89 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -847,7 +847,7 @@ openedx-events==11.2.0 # openedx-core # openedx-platform # ora2 -openedx-filters==3.14.0 +openedx-filters==3.16.0 # via # edx-enterprise # lti-consumer-xblock diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 38a9cb396df3..cf5f7ae177ae 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -949,7 +949,7 @@ openedx-events==11.2.0 # openedx-core # openedx-platform # ora2 -openedx-filters==3.14.0 +openedx-filters==3.16.0 # via # edx-enterprise # lti-consumer-xblock From 3a2150384fde87a8c431e7b883cc84074480a0da Mon Sep 17 00:00:00 2001 From: rahul-apphelix Date: Tue, 20 Oct 2026 17:05:06 +0530 Subject: [PATCH 4/5] fix: review comments fixed --- manage.py | 29 ++++++++- openedx/core/tests/test_manage_py.py | 89 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 openedx/core/tests/test_manage_py.py diff --git a/manage.py b/manage.py index fa3d3a9d4944..5fc6df9e7d5a 100755 --- a/manage.py +++ b/manage.py @@ -76,6 +76,26 @@ def parse_args(): return known_args, remaining_args +# Django options that take a separate value (e.g. `--verbosity 2`), so the value is not mistaken for the command name. +OPTIONS_WITH_VALUE = {'-v', '--verbosity', '--pythonpath', '--settings'} + + +def get_command_name(django_args): + """ + Return the Django command name (e.g. 'migrate', 'runserver') from the arguments left after edX parsing. + + This is the first argument that is not an option, skipping options such as `--verbosity 2`, `-v2` or + `--traceback`. Returns 'no-argument' if there is no such argument. + """ + args = iter(django_args) + for arg in args: + if arg in OPTIONS_WITH_VALUE: + next(args, None) # skip the option's value + elif not arg.startswith('-'): + return arg + return 'no-argument' + + if __name__ == "__main__": edx_args, django_args = parse_args() @@ -94,11 +114,14 @@ def parse_args(): from django.core.management import execute_from_command_line - # First unconsumed argument is treated as the Django command name (e.g. 'migrate', 'runserver'). - # Falls back to 'no-argument' if django_args is empty. - command_name = django_args[0] if django_args else 'no-argument' + command_name = get_command_name(django_args) service_variant = edx_args.settings_base.split('/', maxsplit=1)[0] + # NOTE: This filter runs before django.setup() (inside execute_from_command_line), so the app registry is not + # ready. Pipeline steps are imported at this point and must not import models or other app-registry dependent code + # at module level. + # .. filter_implemented_name: ManagementCommandContextmanagerRequested + # .. filter_type: org.openedx.management.command.contextmanager.requested.v1 command_contextmanager, _, _ = ManagementCommandContextmanagerRequested.run_filter( command_contextmanager=nullcontext(), command_name=command_name, diff --git a/openedx/core/tests/test_manage_py.py b/openedx/core/tests/test_manage_py.py new file mode 100644 index 000000000000..4d6eeaa15822 --- /dev/null +++ b/openedx/core/tests/test_manage_py.py @@ -0,0 +1,89 @@ +""" +Tests for the management command context manager filter hook in the top-level ``manage.py``. +""" +import os +import runpy +import sys +from contextlib import contextmanager +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from openedx_filters.management.filters import ManagementCommandContextmanagerRequested + +MANAGE_PY = str(Path(__file__).resolve().parents[3] / "manage.py") + + +class ManagePyFilterTest(TestCase): + """ + Verify that manage.py runs the command inside the context manager returned by the filter pipeline. + """ + + def _run_manage_py(self, argv, filter_side_effect=None): + """ + Execute manage.py as ``__main__`` with the given argv. + + Returns the mocked ``run_filter`` and ``execute_from_command_line``. + """ + events = [] + + @contextmanager + def tracking_contextmanager(): + events.append("enter") + yield + events.append("exit") + + def fake_run_filter(**kwargs): + return ( + kwargs["command_contextmanager"] if filter_side_effect is None else tracking_contextmanager(), + kwargs["command_name"], + kwargs["service_variant"], + ) + + mock_execute = MagicMock(side_effect=lambda *_a, **_k: events.append("execute")) + filter_patch = patch.object( + ManagementCommandContextmanagerRequested, "run_filter", side_effect=fake_run_filter + ) + with patch.object(sys, "argv", ["manage.py", *argv]), \ + patch.dict(os.environ), \ + filter_patch as mock_filter, \ + patch("django.core.management.execute_from_command_line", mock_execute): + runpy.run_path(MANAGE_PY, run_name="__main__") + return mock_filter, mock_execute, events + + def test_filter_receives_command_name_and_service_variant(self): + mock_filter, mock_execute, _ = self._run_manage_py(["lms", "migrate", "--noinput"]) + + kwargs = mock_filter.call_args.kwargs + assert kwargs["command_name"] == "migrate" + assert kwargs["service_variant"] == "lms" + # runpy sets argv[0] to the absolute path of manage.py, so only compare the arguments after it. + mock_execute.assert_called_once() + assert mock_execute.call_args.args[0][1:] == ["migrate", "--noinput"] + + def test_cms_service_variant(self): + mock_filter, _, _ = self._run_manage_py(["cms", "check"]) + + assert mock_filter.call_args.kwargs["service_variant"] == "cms" + + def test_leading_options_are_skipped_to_find_command_name(self): + mock_filter, mock_execute, _ = self._run_manage_py(["lms", "--verbosity", "2", "--traceback", "migrate"]) + + assert mock_filter.call_args.kwargs["command_name"] == "migrate" + mock_execute.assert_called_once() + assert mock_execute.call_args.args[0][1:] == ["--verbosity", "2", "--traceback", "migrate"] + + def test_options_only_uses_fallback_name(self): + mock_filter, _, _ = self._run_manage_py(["lms", "--traceback"]) + + assert mock_filter.call_args.kwargs["command_name"] == "no-argument" + + def test_no_command_uses_fallback_name(self): + mock_filter, _, _ = self._run_manage_py(["lms"]) + + assert mock_filter.call_args.kwargs["command_name"] == "no-argument" + + def test_command_runs_inside_returned_contextmanager(self): + _, _, events = self._run_manage_py(["lms", "check"], filter_side_effect=True) + + assert events == ["enter", "execute", "exit"] From 358b83d21c47ea0dfe1de0732fbb3b700619b877 Mon Sep 17 00:00:00 2001 From: rahul-apphelix Date: Thu, 22 Oct 2026 11:40:10 +0530 Subject: [PATCH 5/5] chore: require openedx-filters>=3.16.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 68ab6af4e578..80b743af00ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,7 @@ dependencies = [ "openedx-core", "openedx-django-require", "openedx-events", # Open edX Events from Hooks Extension Framework (OEP-50) - "openedx-filters", # Open edX Filters from Hooks Extension Framework (OEP-50) + "openedx-filters>=3.16.0", # openedx-filters 3.16.0 adds ManagementCommandContextmanagerRequested, used by manage.py; Open edX Filters from Hooks Extension Framework (OEP-50) "openedx-forum", # Open edX forum v2 application "openedx-django-wiki", "path", diff --git a/uv.lock b/uv.lock index bf4108f1ef28..4a1ee4f753b4 100644 --- a/uv.lock +++ b/uv.lock @@ -5197,7 +5197,7 @@ requires-dist = [ { name = "openedx-django-require" }, { name = "openedx-django-wiki" }, { name = "openedx-events" }, - { name = "openedx-filters" }, + { name = "openedx-filters", specifier = ">=3.16.0" }, { name = "openedx-forum" }, { name = "path" }, { name = "piexif" },