From 59e0d22fc335b51ce2306cce33c1e14a51487c92 Mon Sep 17 00:00:00 2001 From: utkjad Date: Fri, 29 May 2015 20:49:22 +0000 Subject: [PATCH] Adding call stack manager --- cms/envs/test.py | 2 +- lms/djangoapps/courseware/models.py | 11 +- lms/envs/test.py | 2 +- .../djangoapps/call_stack_manager/__init__.py | 5 + .../djangoapps/call_stack_manager/core.py | 144 ++++++++++++ .../djangoapps/call_stack_manager/models.py | 8 + .../djangoapps/call_stack_manager/tests.py | 215 ++++++++++++++++++ 7 files changed, 377 insertions(+), 10 deletions(-) create mode 100644 openedx/core/djangoapps/call_stack_manager/__init__.py create mode 100644 openedx/core/djangoapps/call_stack_manager/core.py create mode 100644 openedx/core/djangoapps/call_stack_manager/models.py create mode 100644 openedx/core/djangoapps/call_stack_manager/tests.py diff --git a/cms/envs/test.py b/cms/envs/test.py index b637368c9f..08e5fe73ed 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -173,7 +173,7 @@ CACHES = { INSTALLED_APPS += ('external_auth', ) # Add milestones to Installed apps for testing -INSTALLED_APPS += ('milestones', ) +INSTALLED_APPS += ('milestones', 'openedx.core.djangoapps.call_stack_manager') # hide ratelimit warnings while running tests filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit') diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 3c2cd72026..ac7b72520b 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -26,6 +26,7 @@ from student.models import user_by_anonymous_id from submissions.models import score_set, score_reset from xmodule_django.models import CourseKeyField, LocationKeyField, BlockTypeKeyField # pylint: disable=import-error +log = logging.getLogger(__name__) log = logging.getLogger("edx.courseware") @@ -72,7 +73,6 @@ class StudentModule(models.Model): Keeps student state for a particular module in a particular course. """ objects = ChunkingManager() - MODEL_TAGS = ['course_id', 'module_type'] # For a homework problem, contains a JSON @@ -96,10 +96,10 @@ class StudentModule(models.Model): class Meta(object): # pylint: disable=missing-docstring unique_together = (('student', 'module_state_key', 'course_id'),) - ## Internal state of the object + # Internal state of the object state = models.TextField(null=True, blank=True) - ## Grade, and are we done? + # Grade, and are we done? grade = models.FloatField(null=True, blank=True, db_index=True) max_grade = models.FloatField(null=True, blank=True) DONE_TYPES = ( @@ -146,7 +146,6 @@ class StudentModuleHistory(models.Model): """Keeps a complete history of state changes for a given XModule for a given Student. Right now, we restrict this to problems so that the table doesn't explode in size.""" - HISTORY_SAVING_TYPES = {'problem'} class Meta(object): # pylint: disable=missing-docstring @@ -211,7 +210,6 @@ class XModuleUserStateSummaryField(XBlockFieldBase): """ Stores data set in the Scope.user_state_summary scope by an xmodule field """ - class Meta(object): # pylint: disable=missing-docstring unique_together = (('usage_id', 'field_name'),) @@ -223,7 +221,6 @@ class XModuleStudentPrefsField(XBlockFieldBase): """ Stores data set in the Scope.preferences scope by an xmodule field """ - class Meta(object): # pylint: disable=missing-docstring unique_together = (('student', 'module_type', 'field_name'),) @@ -237,10 +234,8 @@ class XModuleStudentInfoField(XBlockFieldBase): """ Stores data set in the Scope.preferences scope by an xmodule field """ - class Meta(object): # pylint: disable=missing-docstring unique_together = (('student', 'field_name'),) - student = models.ForeignKey(User, db_index=True) diff --git a/lms/envs/test.py b/lms/envs/test.py index 5a81e3cca2..d62756b8c3 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -457,7 +457,7 @@ FEATURES['ENABLE_EDXNOTES'] = True FEATURES['ENABLE_TEAMS'] = True # Add milestones to Installed apps for testing -INSTALLED_APPS += ('milestones', ) +INSTALLED_APPS += ('milestones', 'openedx.core.djangoapps.call_stack_manager') # Enable courseware search for tests FEATURES['ENABLE_COURSEWARE_SEARCH'] = True diff --git a/openedx/core/djangoapps/call_stack_manager/__init__.py b/openedx/core/djangoapps/call_stack_manager/__init__.py new file mode 100644 index 0000000000..5682284fa2 --- /dev/null +++ b/openedx/core/djangoapps/call_stack_manager/__init__.py @@ -0,0 +1,5 @@ +""" +Root Package for getting call stacks of various Model classes being used +""" +from __future__ import absolute_import +from .core import CallStackManager, CallStackMixin, donottrack diff --git a/openedx/core/djangoapps/call_stack_manager/core.py b/openedx/core/djangoapps/call_stack_manager/core.py new file mode 100644 index 0000000000..98a63e8dbd --- /dev/null +++ b/openedx/core/djangoapps/call_stack_manager/core.py @@ -0,0 +1,144 @@ +""" +Get call stacks of Model Class +in three cases- +1. QuerySet API +2. save() +3. delete() + +classes: +CallStackManager - stores all stacks in global dictionary and logs +CallStackMixin - used for Model save(), and delete() method + +Functions: +capture_call_stack - global function used to store call stack + +Decorators: +donottrack - mainly for the places where we know the calls. This decorator will let us not to track in specified cases + +How to use- +1. Import following in the file where class to be tracked resides + from openedx.core.djangoapps.call_stack_manager import CallStackManager, CallStackMixin +2. Override objects of default manager by writing following in any model class which you want to track- + objects = CallStackManager() +3. For tracking Save and Delete events- + Use mixin called "CallStackMixin" + For ex. + class StudentModule(CallStackMixin, models.Model): +4. Decorator is a parameterized decorator with class name/s as argument + How to use - + 1. Import following + import from openedx.core.djangoapps.call_stack_manager import donottrack +""" + +import logging +import traceback +import re +import collections +from django.db.models import Manager + +log = logging.getLogger(__name__) + +# list of regular expressions acting as filters +REGULAR_EXPS = [re.compile(x) for x in ['^.*python2.7.*$', '^.*.*$', '^.*exec_code_object.*$', + '^.*edxapp/src.*$', '^.*call_stack_manager.*$']] +# Variable which decides whether to track calls in the function or not. Do it by default. +TRACK_FLAG = True + +# List keeping track of Model classes not be tracked for special cases +# usually cases where we know that the function is calling Model classes. +HALT_TRACKING = [] + +# Module Level variables +# dictionary which stores call stacks. +# { "ModelClasses" : [ListOfFrames]} +# Frames - ('FilePath','LineNumber','Context') +# ex. {"" : [[(file,line number,context),(---,---,---)], +# [(file,line number,context),(---,---,---)]]} +STACK_BOOK = collections.defaultdict(list) + + +def capture_call_stack(current_model): + """ logs customised call stacks in global dictionary `STACK_BOOK`, and logs it. + + Args: + current_model - Name of the model class + """ + # holds temporary callstack + # frame[0][6:-1] -> File name along with path + # frame[1][6:] -> Line Number + # frame[2][3:] -> Context + temp_call_stack = [(frame[0][6:-1], + frame[1][6:], + frame[2][3:]) + for frame in [stack.replace("\n", "").strip().split(',') for stack in traceback.format_stack()] + if not any(reg.match(frame[0]) for reg in REGULAR_EXPS)] + + # avoid duplication. + if temp_call_stack not in STACK_BOOK[current_model] and TRACK_FLAG \ + and not issubclass(current_model, tuple(HALT_TRACKING)): + STACK_BOOK[current_model].append(temp_call_stack) + log.info("logging new call stack for %s:\n %s", current_model, temp_call_stack) + + +class CallStackMixin(object): + """ A mixin class for getting call stacks when Save() and Delete() methods are called + """ + + def save(self, *args, **kwargs): + """ + Logs before save and overrides respective model API save() + """ + capture_call_stack(type(self)) + return super(CallStackMixin, self).save(*args, **kwargs) + + def delete(self, *args, **kwargs): + """ + Logs before delete and overrides respective model API delete() + """ + capture_call_stack(type(self)) + return super(CallStackMixin, self).delete(*args, **kwargs) + + +class CallStackManager(Manager): + """ A Manager class which overrides the default Manager class for getting call stacks + """ + def get_query_set(self): + """overriding the default queryset API method + """ + capture_call_stack(self.model) + return super(CallStackManager, self).get_query_set() + + +def donottrack(*classes_not_to_be_tracked): + """function decorator which deals with toggling call stack + Args: + classes_not_to_be_tracked: model classes where tracking is undesirable + Returns: + wrapped function + """ + + def real_donottrack(function): + """takes function to be decorated and returns wrapped function + + Args: + function - wrapped function i.e. real_donottrack + """ + def wrapper(*args, **kwargs): + """ wrapper function for decorated function + Returns: + wrapper function i.e. wrapper + """ + if len(classes_not_to_be_tracked) == 0: + global TRACK_FLAG # pylint: disable=W0603 + current_flag = TRACK_FLAG + TRACK_FLAG = False + function(*args, **kwargs) + TRACK_FLAG = current_flag + else: + global HALT_TRACKING # pylint: disable=W0603 + current_halt_track = HALT_TRACKING + HALT_TRACKING = classes_not_to_be_tracked + function(*args, **kwargs) + HALT_TRACKING = current_halt_track + return wrapper + return real_donottrack diff --git a/openedx/core/djangoapps/call_stack_manager/models.py b/openedx/core/djangoapps/call_stack_manager/models.py new file mode 100644 index 0000000000..4069b5477d --- /dev/null +++ b/openedx/core/djangoapps/call_stack_manager/models.py @@ -0,0 +1,8 @@ +""" +Dummy models.py file + +Note - +django-nose loads models for tests, but only if the django app that the test is contained in has models itself. +This file is empty so that the unit tests can have models. +For call_stack_manager - models specific to tests are defined in tests.py +""" diff --git a/openedx/core/djangoapps/call_stack_manager/tests.py b/openedx/core/djangoapps/call_stack_manager/tests.py new file mode 100644 index 0000000000..7112e01ec5 --- /dev/null +++ b/openedx/core/djangoapps/call_stack_manager/tests.py @@ -0,0 +1,215 @@ +""" +Test cases for Call Stack Manager +""" +from mock import patch +from django.db import models +from django.test import TestCase + +from openedx.core.djangoapps.call_stack_manager import donottrack, CallStackManager, CallStackMixin + + +class ModelMixinCallStckMngr(CallStackMixin, models.Model): + """ + Test Model class which uses both CallStackManager, and CallStackMixin + """ + # override Manager objects + objects = CallStackManager() + id_field = models.IntegerField() + + +class ModelMixin(CallStackMixin, models.Model): + """ + Test Model that uses CallStackMixin but does not use CallStackManager + """ + id_field = models.IntegerField() + + +class ModelNothingCallStckMngr(models.Model): + """ + Test Model class that neither uses CallStackMixin nor CallStackManager + """ + id_field = models.IntegerField() + + +class ModelAnotherCallStckMngr(models.Model): + """ + Test Model class that only uses overridden Manager CallStackManager + """ + objects = CallStackManager() + id_field = models.IntegerField() + + +class ModelWithCallStackMngr(models.Model): + """ + Test Model Class with overridden CallStackManager + """ + objects = CallStackManager() + id_field = models.IntegerField() + + +class ModelWithCallStckMngrChild(ModelWithCallStackMngr): + """child class of ModelWithCallStackMngr + """ + objects = CallStackManager() + child_id_field = models.IntegerField() + + +@donottrack(ModelWithCallStackMngr) +def donottrack_subclass(): + """ function in which subclass and superclass calls QuerySetAPI + """ + ModelWithCallStackMngr.objects.filter(id_field=1) + ModelWithCallStckMngrChild.objects.filter(child_id_field=1) + + +def track_without_donottrack(): + """ function calling QuerySetAPI, another function, again QuerySetAPI + """ + ModelAnotherCallStckMngr.objects.filter(id_field=1) + donottrack_child_func() + ModelAnotherCallStckMngr.objects.filter(id_field=1) + + +@donottrack(ModelAnotherCallStckMngr) +def donottrack_child_func(): + """ decorated child function + """ + # should not be tracked + ModelAnotherCallStckMngr.objects.filter(id_field=1) + + # should be tracked + ModelMixinCallStckMngr.objects.filter(id_field=1) + + +@donottrack(ModelMixinCallStckMngr) +def donottrack_parent_func(): + """ decorated parent function + """ + # should not be tracked + ModelMixinCallStckMngr.objects.filter(id_field=1) + # should be tracked + ModelAnotherCallStckMngr.objects.filter(id_field=1) + donottrack_child_func() + + +@donottrack() +def donottrack_func_parent(): + """ non-parameterized @donottrack decorated function calling child function + """ + ModelMixin.objects.all() + donottrack_func_child() + ModelMixin.objects.filter(id_field=1) + + +@donottrack() +def donottrack_func_child(): + """ child decorated non-parameterized function + """ + # Should not be tracked + ModelMixin.objects.all() + + +@patch('openedx.core.djangoapps.call_stack_manager.core.log.info') +@patch('openedx.core.djangoapps.call_stack_manager.core.REGULAR_EXPS', []) +class TestingCallStackManager(TestCase): + """Tests for call_stack_manager + 1. Tests CallStackManager QuerySetAPI functionality + 2. Tests @donottrack decorator + """ + def test_save(self, log_capt): + """ tests save() of CallStackMixin/ applies same for delete() + classes with CallStackMixin should participate in logging. + """ + ModelMixin(id_field=1).save() + self.assertEqual(ModelMixin, log_capt.call_args[0][1]) + + def test_withoutmixin_save(self, log_capt): + """tests save() of CallStackMixin/ applies same for delete() + classes without CallStackMixin should not participate in logging + """ + ModelAnotherCallStckMngr(id_field=1).save() + self.assertEqual(len(log_capt.call_args_list), 0) + + def test_queryset(self, log_capt): + """ Tests for Overriding QuerySet API + classes with CallStackManager should get logged. + """ + ModelAnotherCallStckMngr(id_field=1).save() + ModelAnotherCallStckMngr.objects.all() + self.assertEqual(ModelAnotherCallStckMngr, log_capt.call_args[0][1]) + + def test_withoutqueryset(self, log_capt): + """ Tests for Overriding QuerySet API + classes without CallStackManager should not get logged + """ + # create and save objects of class not overriding queryset API + ModelNothingCallStckMngr(id_field=1).save() + # class not using Manager, should not get logged + ModelNothingCallStckMngr.objects.all() + self.assertEqual(len(log_capt.call_args_list), 0) + + def test_donottrack(self, log_capt): + """ Test for @donottrack + calls in decorated function should not get logged + """ + donottrack_func_parent() + self.assertEqual(len(log_capt.call_args_list), 0) + + def test_parameterized_donottrack(self, log_capt): + """ Test for parameterized @donottrack + classes specified in the decorator @donottrack should not get logged + """ + ModelAnotherCallStckMngr(id_field=1).save() + ModelMixinCallStckMngr(id_field=1).save() + donottrack_child_func() + self.assertEqual(ModelMixinCallStckMngr, log_capt.call_args[0][1]) + + def test_nested_parameterized_donottrack(self, log_capt): + """ Tests parameterized nested @donottrack + should not track call of classes specified in decorated with scope bounded to the respective class + """ + ModelAnotherCallStckMngr(id_field=1).save() + donottrack_parent_func() + self.assertEqual(ModelAnotherCallStckMngr, log_capt.call_args_list[0][0][1]) + self.assertEqual(ModelMixinCallStckMngr, log_capt.call_args_list[1][0][1]) + + def test_nested_parameterized_donottrack_after(self, log_capt): + """ Tests parameterized nested @donottrack + QuerySetAPI calls after calling function with @donottrack should get logged + """ + donottrack_child_func() + # class with CallStackManager as Manager + ModelAnotherCallStckMngr(id_field=1).save() + # test is this- that this should get called. + ModelAnotherCallStckMngr.objects.filter(id_field=1) + self.assertEqual(ModelMixinCallStckMngr, log_capt.call_args_list[0][0][1]) + self.assertEqual(ModelAnotherCallStckMngr, log_capt.call_args_list[1][0][1]) + + def test_donottrack_called_in_func(self, log_capt): + """ test for function which calls decorated function + functions without @donottrack decorator should log + """ + ModelAnotherCallStckMngr(id_field=1).save() + ModelMixinCallStckMngr(id_field=1).save() + track_without_donottrack() + self.assertEqual(ModelMixinCallStckMngr, log_capt.call_args_list[0][0][1]) + self.assertEqual(ModelAnotherCallStckMngr, log_capt.call_args_list[1][0][1]) + self.assertEqual(ModelMixinCallStckMngr, log_capt.call_args_list[2][0][1]) + self.assertEqual(ModelAnotherCallStckMngr, log_capt.call_args_list[3][0][1]) + + def test_donottrack_child_too(self, log_capt): + """ Test for inheritance + subclass should not be tracked when superclass is called in a @donottrack decorated function + """ + ModelWithCallStackMngr(id_field=1).save() + ModelWithCallStckMngrChild(id_field=1, child_id_field=1).save() + donottrack_subclass() + self.assertEqual(len(log_capt.call_args_list), 0) + + def test_duplication(self, log_capt): + """ Test for duplication of call stacks + should not log duplicated call stacks + """ + for __ in range(1, 5): + ModelMixinCallStckMngr(id_field=1).save() + self.assertEqual(len(log_capt.call_args_list), 1)