diff --git a/edx_arch_experiments/management/commands/__init__.py b/edx_arch_experiments/management/commands/__init__.py index 0be19bf..e69de29 100644 --- a/edx_arch_experiments/management/commands/__init__.py +++ b/edx_arch_experiments/management/commands/__init__.py @@ -1 +0,0 @@ -__all__ = ['ManufactureDataCommand'] \ No newline at end of file diff --git a/edx_arch_experiments/management/commands/manufacture_data.py b/edx_arch_experiments/management/commands/manufacture_data.py index 365e824..686d134 100644 --- a/edx_arch_experiments/management/commands/manufacture_data.py +++ b/edx_arch_experiments/management/commands/manufacture_data.py @@ -12,17 +12,7 @@ from django.db import connections from factory.declarations import SubFactory -# We have to import the enterprise test factories to ensure it's loaded and found by __subclasses__ -# To ensure factories outside of the enterprise package are loaded and found by the script, -# add any additionally desired factories as an import to this file. Make sure to catch the ImportError -# incase other consumers of the command do not have the same factories installed. -# For example: -# try: -# import common.djangoapps.student.tests.factories # pylint: disable=unused-import - -# from test_utils import factories # pylint: disable=unused-import -# except ImportError: -# pass +# TODO: Document usage log = logging.getLogger(__name__) @@ -216,7 +206,7 @@ def build_tree_from_field_list(list_of_fields, provided_factory, base_node, cust return base_node -class ManufactureDataCommand(BaseCommand): +class Command(BaseCommand): """ Management command for generating Django records from factories with custom attributes diff --git a/edx_arch_experiments/tests/__init__.py b/edx_arch_experiments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edx_arch_experiments/tests/management/__init__.py b/edx_arch_experiments/tests/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edx_arch_experiments/tests/management/test_management.py b/edx_arch_experiments/tests/management/test_management.py new file mode 100644 index 0000000..5349928 --- /dev/null +++ b/edx_arch_experiments/tests/management/test_management.py @@ -0,0 +1,239 @@ +""" +Test management commands and related functions. +""" + +from argparse import _AppendConstAction, _CountAction, _StoreConstAction, _SubParsersAction +from pytest import mark + +from django.test.utils import isolate_apps +from django.core.management import get_commands, load_command_class +from django.core.management.base import BaseCommand, CommandError +from django.test import TestCase + +import factory +from django.db import models + + +# Copied from django.core.management.__init__.py +# https://github.com/django/django/blob/1ad7761ee616341295f36c80f78b86ff79d5b513/django/core/management/__init__.py#L83 +def call_command(command_name, *args, **options): + """ + Call the given command, with the given options and args/kwargs. + + This is the primary API you should use for calling specific commands. + + `command_name` may be a string or a command object. Using a string is + preferred unless the command object is required for further processing or + testing. + + Some examples: + call_command('migrate') + call_command('shell', plain=True) + call_command('sqlmigrate', 'myapp') + + from django.core.management.commands import flush + cmd = flush.Command() + call_command(cmd, verbosity=0, interactive=False) + # Do something with cmd ... + """ + if isinstance(command_name, BaseCommand): + # Command object passed in. + command = command_name + command_name = command.__class__.__module__.split(".")[-1] + else: + # Load the command object by name. + try: + app_name = get_commands()[command_name] + except KeyError: + raise CommandError("Unknown command: %r" % command_name) # pylint: disable=raise-missing-from + + if isinstance(app_name, BaseCommand): + # If the command is already loaded, use it directly. + command = app_name + else: + command = load_command_class(app_name, command_name) + + # Simulate argument parsing to get the option defaults (see #10080 for details). + parser = command.create_parser("", command_name) + # Use the `dest` option name from the parser option + opt_mapping = { + min(s_opt.option_strings).lstrip("-").replace("-", "_"): s_opt.dest + for s_opt in parser._actions # pylint: disable=protected-access + if s_opt.option_strings + } + arg_options = {opt_mapping.get(key, key): value for key, value in options.items()} + parse_args = [] + for arg in args: + if isinstance(arg, (list, tuple)): + parse_args += map(str, arg) + else: + parse_args.append(str(arg)) + + def get_actions(parser): + # Parser actions and actions from sub-parser choices. + for opt in parser._actions: # pylint: disable=protected-access + if isinstance(opt, _SubParsersAction): + for sub_opt in opt.choices.values(): + yield from get_actions(sub_opt) + else: + yield opt + + parser_actions = list(get_actions(parser)) + mutually_exclusive_required_options = { + opt + for group in parser._mutually_exclusive_groups # pylint: disable=protected-access + for opt in group._group_actions # pylint: disable=protected-access + if group.required + } + # Any required arguments which are passed in via **options must be passed + # to parse_args(). + for opt in parser_actions: + if opt.dest in options and ( + opt.required or opt in mutually_exclusive_required_options + ): + opt_dest_count = sum(v == opt.dest for v in opt_mapping.values()) + if opt_dest_count > 1: + raise TypeError( + f"Cannot pass the dest {opt.dest!r} that matches multiple " + f"arguments via **options." + ) + parse_args.append(min(opt.option_strings)) + if isinstance(opt, (_AppendConstAction, _CountAction, _StoreConstAction)): + continue + value = arg_options[opt.dest] + if isinstance(value, (list, tuple)): + parse_args += map(str, value) + else: + parse_args.append(str(value)) + defaults = parser.parse_args(args=parse_args) + + defaults = dict(defaults._get_kwargs(), **arg_options) # pylint: disable=protected-access + # Commented out section allows for unknown options to be passed to the command + + # Raise an error if any unknown options were passed. + # stealth_options = set(command.base_stealth_options + command.stealth_options) + # dest_parameters = {action.dest for action in parser_actions} + # valid_options = (dest_parameters | stealth_options).union(opt_mapping) + # unknown_options = set(options) - valid_options + # if unknown_options: + # raise TypeError( + # "Unknown option(s) for %s command: %s. " + # "Valid options are: %s." + # % ( + # command_name, + # ", ".join(sorted(unknown_options)), + # ", ".join(sorted(valid_options)), + # ) + # ) + # Move positional args out of options to mimic legacy optparse + args = defaults.pop("args", ()) + if "skip_checks" not in options: + defaults["skip_checks"] = True + + return command.execute(*args, **defaults) + + +@mark.django_db +class ManufactureDataCommandTests(TestCase): + """ + Test command `manufacture_data`. + """ + command = 'manufacture_data' + + def test_command_requires_model(self): + """ + Test that the manufacture_data command will raise an error if no model is provided. + """ + with self.assertRaises(CommandError): + call_command(self.command) + + def test_command_requires_valid_model(self): + """ + Test that the manufacture_data command will raise an error if the provided model is invalid. + """ + with self.assertRaises(CommandError): + call_command(self.command, model='FakeModel') + + @isolate_apps("edx_arch_experiments") + def test_model_definition(self): + class TestPerson(models.Model): + first_name = models.CharField(max_length=30) + last_name = models.CharField(max_length=30) + + + class TestPersonContactInfo(models.Model): + person = models.ForeignKey(TestPerson, on_delete=models.CASCADE) + address = models.CharField(max_length=100) + + + class TestPersonUserFactory(factory.django.DjangoModelFactory): + """ + Test Factory for TestPerson + """ + class Meta: + model = TestPerson + + first_name = 'John' + last_name = 'Doe' + + + class TestPersonContactInfoFactory(factory.django.DjangoModelFactory): + """ + Test Factory for TestPersonContactInfo + """ + class Meta: + model = TestPersonContactInfo + + address = '123 4th st, Fiveville, AZ, 67890' + command = 'manufacture_data' + + # def test_single_object_create_no_customizations(self): + """ + Test that the manufacture_data command will create a single object with no customizations. + """ + assert TestPerson.objects.all().count() == 0 + created_object = call_command(self.command, model='TestPerson') + assert TestPerson.objects.all().count() == 1 + assert TestPerson.objects.filter(pk=created_object).exists() + + # def test_command_requires_valid_field(self): + """ + Test that the manufacture_data command will raise an error if the provided field is invalid. + """ + with self.assertRaises(CommandError): + call_command( + self.command, + model='TestPerson', + field_customizations={"fake_field": 'fake_value'} + ) + + # def test_command_can_customize_fields(self): + """ + Test that the manufacture_data command will create a single object with customizations. + """ + assert TestPerson.objects.all().count() == 0 + created_object = call_command( + self.command, + model='TestPerson', + field_customizations={'first_name': 'Steve'}, + ) + assert TestPerson.objects.all().count() == 1 + assert TestPerson.objects.filter(pk=created_object).exists() + assert TestPerson.objects.filter(pk=created_object).first().first_name == 'Steve' + + # def test_command_can_customize_nested_objects(self): + """ + Test that the manufacture_data command supports customizing nested objects. + """ + assert TestPerson.objects.all().count() == 0 + assert TestPersonContactInfo.objects.all().count() == 0 + created_object = call_command( + self.command, + model='TestPersonContactInfo', + field_customizations={'person__last_name': 'Nowhere'}, + ) + assert TestPerson.objects.all().count() == 1 + assert TestPersonContactInfo.objects.all().count() == 1 + assert TestPersonContactInfo.objects.filter( + pk=created_object + ).first().person.last_name == 'Nowhere' diff --git a/requirements/dev.txt b/requirements/dev.txt index 37918bd..ba9ffca 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -17,7 +17,7 @@ bleach==6.0.0 # via # -r requirements/quality.txt # readme-renderer -build==1.0.0 +build==1.0.3 # via # -r requirements/pip-tools.txt # pip-tools @@ -61,6 +61,8 @@ cryptography==41.0.3 # via # -r requirements/quality.txt # secretstorage +ddt==1.3.1 + # via -r requirements/quality.txt diff-cover==7.7.0 # via -r requirements/dev.in dill==0.3.7 @@ -101,6 +103,12 @@ exceptiongroup==1.1.3 # via # -r requirements/quality.txt # pytest +factory-boy==3.3.0 + # via -r requirements/quality.txt +faker==19.4.0 + # via + # -r requirements/quality.txt + # factory-boy filelock==3.12.3 # via # -r requirements/ci.txt @@ -260,7 +268,7 @@ pyproject-hooks==1.0.0 # via # -r requirements/pip-tools.txt # build -pytest==7.4.1 +pytest==7.4.2 # via # -r requirements/quality.txt # pytest-cov @@ -269,6 +277,10 @@ pytest-cov==4.1.0 # via -r requirements/quality.txt pytest-django==4.5.2 # via -r requirements/quality.txt +python-dateutil==2.8.2 + # via + # -r requirements/quality.txt + # faker python-slugify==8.0.1 # via # -r requirements/quality.txt @@ -313,6 +325,7 @@ six==1.16.0 # -r requirements/quality.txt # bleach # edx-lint + # python-dateutil # tox snowballstemmer==2.2.0 # via @@ -362,6 +375,7 @@ typing-extensions==4.7.1 # -r requirements/quality.txt # asgiref # astroid + # faker # filelock # pylint # rich diff --git a/requirements/doc.txt b/requirements/doc.txt index 7aa231e..620fc9f 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -33,6 +33,8 @@ coverage[toml]==7.3.1 # via # -r requirements/test.txt # pytest-cov +ddt==1.3.1 + # via -r requirements/test.txt django==3.2.21 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt @@ -65,9 +67,13 @@ exceptiongroup==1.1.3 # -r requirements/test.txt # pytest factory-boy==3.3.0 - # via -r requirements/doc.in -faker==19.3.1 - # via factory-boy + # via + # -r requirements/doc.in + # -r requirements/test.txt +faker==19.4.0 + # via + # -r requirements/test.txt + # factory-boy idna==3.4 # via requests imagesize==1.4.1 @@ -121,7 +127,7 @@ pynacl==1.5.0 # via # -r requirements/test.txt # edx-django-utils -pytest==7.4.1 +pytest==7.4.2 # via # -r requirements/doc.in # -r requirements/test.txt @@ -132,7 +138,9 @@ pytest-cov==4.1.0 pytest-django==4.5.2 # via -r requirements/test.txt python-dateutil==2.8.2 - # via faker + # via + # -r requirements/test.txt + # faker python-slugify==8.0.1 # via # -r requirements/test.txt @@ -154,6 +162,7 @@ restructuredtext-lint==1.4.0 # via doc8 six==1.16.0 # via + # -r requirements/test.txt # bleach # edx-sphinx-theme # python-dateutil diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 135c9d9..d2e8e4e 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -4,7 +4,7 @@ # # make upgrade # -build==1.0.0 +build==1.0.3 # via pip-tools click==8.1.7 # via pip-tools diff --git a/requirements/pip.txt b/requirements/pip.txt index 13c7e84..da0741c 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -10,5 +10,5 @@ wheel==0.41.2 # The following packages are considered to be unsafe in a requirements file: pip==23.2.1 # via -r requirements/pip.in -setuptools==68.1.2 +setuptools==68.2.0 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index 0c77dee..bd6b072 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -42,6 +42,8 @@ coverage[toml]==7.3.1 # pytest-cov cryptography==41.0.3 # via secretstorage +ddt==1.3.1 + # via -r requirements/test.txt dill==0.3.7 # via pylint django==3.2.21 @@ -69,6 +71,12 @@ exceptiongroup==1.1.3 # via # -r requirements/test.txt # pytest +factory-boy==3.3.0 + # via -r requirements/test.txt +faker==19.4.0 + # via + # -r requirements/test.txt + # factory-boy idna==3.4 # via requests importlib-metadata==6.8.0 @@ -165,7 +173,7 @@ pynacl==1.5.0 # via # -r requirements/test.txt # edx-django-utils -pytest==7.4.1 +pytest==7.4.2 # via # -r requirements/test.txt # pytest-cov @@ -174,6 +182,10 @@ pytest-cov==4.1.0 # via -r requirements/test.txt pytest-django==4.5.2 # via -r requirements/test.txt +python-dateutil==2.8.2 + # via + # -r requirements/test.txt + # faker python-slugify==8.0.1 # via # -r requirements/test.txt @@ -202,8 +214,10 @@ secretstorage==3.3.3 # via keyring six==1.16.0 # via + # -r requirements/test.txt # bleach # edx-lint + # python-dateutil snowballstemmer==2.2.0 # via pydocstyle sqlparse==0.4.4 @@ -234,6 +248,7 @@ typing-extensions==4.7.1 # -r requirements/test.txt # asgiref # astroid + # faker # pylint # rich urllib3==2.0.4 diff --git a/requirements/scripts.txt b/requirements/scripts.txt index abd4f7c..2ab1098 100644 --- a/requirements/scripts.txt +++ b/requirements/scripts.txt @@ -60,7 +60,7 @@ edx-opaque-keys[django]==2.5.0 # via openedx-events edx-toggles==5.1.0 # via edx-event-bus-kafka -fastavro==1.8.2 +fastavro==1.8.3 # via # confluent-kafka # openedx-events diff --git a/requirements/test.in b/requirements/test.in index 6797160..cb3b86e 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -3,6 +3,8 @@ -r base.txt # Core dependencies for this package +ddt<1.4.0 # data-driven tests +factory_boy # Test factory framework pytest-cov # pytest extension for code coverage statistics pytest-django # pytest extension for better Django support code-annotations # provides commands used by the pii_check make target. diff --git a/requirements/test.txt b/requirements/test.txt index e061cc7..306ab29 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -21,6 +21,8 @@ code-annotations==1.5.0 # via -r requirements/test.in coverage[toml]==7.3.1 # via pytest-cov +ddt==1.3.1 + # via -r requirements/test.in # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt @@ -39,6 +41,10 @@ edx-django-utils==5.7.0 # via -r requirements/base.txt exceptiongroup==1.1.3 # via pytest +factory-boy==3.3.0 + # via -r requirements/test.in +faker==19.4.0 + # via factory-boy iniconfig==2.0.0 # via pytest jinja2==3.1.2 @@ -69,7 +75,7 @@ pynacl==1.5.0 # via # -r requirements/base.txt # edx-django-utils -pytest==7.4.1 +pytest==7.4.2 # via # pytest-cov # pytest-django @@ -77,6 +83,8 @@ pytest-cov==4.1.0 # via -r requirements/test.in pytest-django==4.5.2 # via -r requirements/test.in +python-dateutil==2.8.2 + # via faker python-slugify==8.0.1 # via code-annotations pytz==2023.3.post1 @@ -85,6 +93,8 @@ pytz==2023.3.post1 # django pyyaml==6.0.1 # via code-annotations +six==1.16.0 + # via python-dateutil sqlparse==0.4.4 # via # -r requirements/base.txt @@ -104,3 +114,4 @@ typing-extensions==4.7.1 # via # -r requirements/base.txt # asgiref + # faker diff --git a/test_settings.py b/test_settings.py index 493311f..6f68939 100644 --- a/test_settings.py +++ b/test_settings.py @@ -33,6 +33,7 @@ def root(*args): 'django.contrib.messages', 'django.contrib.sessions', 'edx_arch_experiments', + # 'tests.management' ) LOCALE_PATHS = [