diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7375083..0cc8ea5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Unreleased * Add script to get github action errors * Add script to republish failed events +[2.1.0] - 2023-06-01 +~~~~~~~~~~~~~~~~~~~~ + +* Adds test factory + [2.0.0] - 2023-06-01 ~~~~~~~~~~~~~~~~~~~~ diff --git a/edx_arch_experiments/__init__.py b/edx_arch_experiments/__init__.py index 71ff168..efb7336 100644 --- a/edx_arch_experiments/__init__.py +++ b/edx_arch_experiments/__init__.py @@ -2,4 +2,4 @@ A plugin to include applications under development by the architecture team at 2U. """ -__version__ = '2.0.0' +__version__ = '2.1.0' diff --git a/edx_arch_experiments/management/__init__.py b/edx_arch_experiments/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edx_arch_experiments/management/commands/__init__.py b/edx_arch_experiments/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edx_arch_experiments/management/commands/manufacture_data.py b/edx_arch_experiments/management/commands/manufacture_data.py new file mode 100644 index 0000000..ea5b289 --- /dev/null +++ b/edx_arch_experiments/management/commands/manufacture_data.py @@ -0,0 +1,369 @@ +""" +Management command for making things with test factories + +Arguments +======== + +--model: complete path to a model that has a corresponding test factory +--{model_attribute}: (Optional) Value of a model's attribute that will override test factory's default attribute value +--{model_foreignkey__foreignkey_attribute}: (Optional) Value of a model's attribute + that will override test factory's default attribute value + + +Examples +======== + +./manage.py lms manufacture_data --model enterprise.models.EnterpriseCustomer +This will generate an enterprise customer record with placeholder values according to the test factory + +./manage.py lms manufacture_data --model enterprise.models.EnterpriseCustomer --name "FRED" +will produce the customized record: +'EnterpriseCustomer' fields: {'name': 'FRED'} + +./manage.py lms manufacture_data --model enterprise.models.EnterpriseCustomerCatalog / + --enterprise_customer__site__name "Fred" --enterprise_catalog_query__title "JOE SHMO" --title "who?" +will result in: +'EnterpriseCustomerCatalog' fields: {'title': 'who?'} + 'EnterpriseCustomer' fields: {} + 'Site' fields: {'name': 'Fred'} + 'EnterpriseCatalogQuery' fields: {'title': 'JOE SHMO'} + +To supply an existing record as a FK to our object: +./manage.py lms manufacture_data --model enterprise.models.EnterpriseCustomerUser / + --enterprise_customer 994599e6-3787-48ba-a2d1-42d1bdf6c46e +'EnterpriseCustomerUser' fields: {} + 'EnterpriseCustomer' PK: 994599e6-3787-48ba-a2d1-42d1bdf6c46e + +or we can do something like: +./manage.py lms manufacture_data --model enterprise.models.EnterpriseCustomerUser / + --enterprise_customer__site 9 --enterprise_customer__name "joe" +which would yield: +'EnterpriseCustomerUser' fields: {} + 'EnterpriseCustomer' fields: {'name': 'joe'} + 'Site' PK: 9 + + +Errors +====== + +But if you try and get something that doesn't exist... +./manage.py lms manufacture_data --model enterprise.models.EnterpriseCustomerUser --enterprise_customer +we'd get: +CommandError: Provided FK value: does not exist on EnterpriseCustomer + +Another limitation of this script is that it can only fetch or customize, you cannot customize a specified, existing FK + ./manage.py lms manufacture_data --model enterprise.models.EnterpriseCustomerUser / + --enterprise_customer__site__name "fred" --enterprise_customer 994599e6-3787-48ba-a2d1-42d1bdf6c46e +would yield CommandError: This script does not support customizing provided existing objects +""" + +import logging +import re +import sys + +import factory +from django.core.exceptions import ImproperlyConfigured +from django.core.management.base import BaseCommand, CommandError, SystemCheckError, handle_default_options +from django.db import connections +from factory.declarations import SubFactory + +log = logging.getLogger(__name__) + + +def convert_to_pascal(string): + """ + helper method to convert strings to Pascal case. + """ + return string.replace("_", " ").title().replace(" ", "") + + +def pairwise(iterable): + """ + Convert a list into a list of tuples of adjacent elements. + s -> [ (s0, s1), (s2, s3), (s4, s5), ... ] + """ + a = iter(iterable) + return zip(a, a) + + +def all_subclasses(cls): + """ + Recursively get all subclasses of a class + https://stackoverflow.com/a/3862957 + """ + return set(cls.__subclasses__()).union( + [s for c in cls.__subclasses__() for s in all_subclasses(c)]) + + +def convert_to_snake(string): + """ + Helper method to convert strings to snake case. + """ + return re.sub(r'(?' + + +def build_tree_from_field_list(list_of_fields, provided_factory, base_node, customization_value): + """ + Builds a non-binary tree of nodes based on a list of children nodes, using a base node and it's associated data + factory as the parent node the user provided value as a reference to a potential, existing record. + + - list_of_fields (list of strings): the linked list of associated objects to create. Example- + ['enterprise_customer_user', 'enterprise_customer', 'site'] + - provided_factory (factory.django.DjangoModelFactory): The data factory of the base_node. + - base_node (Node): The parent node of the desired tree to build. + - customization_value (string): The value to be assigned to the object associated with the last value in the + ``list_of_fields`` param. Can either be a FK if the last value is a subfactory, or alternatively + a custom value to be assigned to the field. Example- + list_of_fields = ['enterprise_customer_user', 'enterprise_customer', 'site'], + customization_value = 9 + or + list_of_fields = ['enterprise_customer_user', 'enterprise_customer', 'name'], + customization_value = "FRED" + """ + current_factory = provided_factory + current_node = base_node + for index, value in enumerate(list_of_fields): + try: + # First we need to figure out if the current field is a sub factory or not + f = getattr(current_factory, value) + if isinstance(f, SubFactory): + fk_object = None + f_model = f.get_factory()._meta.get_model_class() + + # if we're at the end of the list + if index == len(list_of_fields) - 1: + # verify that the provided customization value is a valid pk for the model + try: + fk_object = f_model.objects.get(pk=customization_value) + except f_model.DoesNotExist as exc: + raise CommandError( + f"Provided FK value: {customization_value} does not exist on {f_model.__name__}" + ) from exc + + # Look for the node in the tree + if node := current_node.find_value(f_model.__name__): + # Not supporting customizations and FK's + if (bool(node.customizations) or bool(node.children)) and bool(fk_object): + raise CommandError("This script does not support customizing provided existing objects") + # If we found the valid FK earlier, assign it to the node + if fk_object: + node.instance = fk_object + # Add the field to the children of the current node + if node not in current_node.children: + current_node.add_child(node) + # Set current node and move on + current_node = node + else: + # Create a new node + node = Node( + f_model.__name__, + ) + node.factory = f.get_factory() + # If we found the valid FK earlier, assign it to the node + if fk_object: + node.instance = fk_object + # Add the field to the children of the current node + current_node.add_child(node) + + current_node = node + current_factory = f.get_factory() + else: + if current_node.instance: + raise CommandError("This script cannot modify existing objects") + current_node.set_single_customization(value, customization_value) + except AttributeError as exc: + log.error(f'Could not find value: {value} in factory: {current_factory}') + raise CommandError(f'Could not find value: {value} in factory: {current_factory}') from exc + return base_node + + +class Command(BaseCommand): + """ + Management command for generating Django records from factories with custom attributes + + Example usage: + $ ./manage.py manufacture_data --model enterprise.models.enterprise_customer \ + --name "Test Enterprise" --slug "test-enterprise" + """ + + def add_arguments(self, parser): + parser.add_argument( + '--model', + dest='model', + help='The model for which the record will be written', + ) + + def run_from_argv(self, argv): + """ + Re-implemented from https://github.com/django/django/blob/main/django/core/management/base.py#L395 in order to + support individual field customization. We will need to keep this method up to date with our current version of + Django BaseCommand. + + Uses ``parse_known_args`` instead of ``parse_args`` to not throw an error when encountering unknown arguments + + https://docs.python.org/3.8/library/argparse.html#argparse.ArgumentParser.parse_known_args + """ + self._called_from_command_line = True + parser = self.create_parser(argv[0], argv[1]) + options, unknown = parser.parse_known_args(argv[2:]) + + # Add the unknowns into the options for use of the handle method + paired_unknowns = pairwise(unknown) + field_customizations = {} + for field, value in paired_unknowns: + field_customizations[field.strip("--")] = value + options.field_customizations = field_customizations + + cmd_options = vars(options) + # Move positional args out of options to mimic legacy optparse + args = cmd_options.pop("args", ()) + handle_default_options(options) + try: + self.execute(*args, **cmd_options) + except CommandError as e: + if options.traceback: + raise + + # SystemCheckError takes care of its own formatting. + if isinstance(e, SystemCheckError): + self.stderr.write(str(e), lambda x: x) + else: + self.stderr.write("%s: %s" % (e.__class__.__name__, e)) + sys.exit(e.returncode) + finally: + try: + connections.close_all() + except ImproperlyConfigured: + # Ignore if connections aren't setup at this point (e.g. no + # configured settings). + pass + + def handle(self, *args, **options): + """ + Entry point for management command execution. + """ + if not options.get('model'): + log.error("Did not receive a model") + raise CommandError("Did not receive a model") + + # Convert to Pascal case if the provided name is snake case/is all lowercase + path_of_model = options.get('model').split(".") + if '_' in path_of_model[-1] or path_of_model[-1].islower(): + last_path = convert_to_pascal(path_of_model[-1]) + else: + last_path = path_of_model[-1] + + provided_model = '.'.join(path_of_model[:-1]) + '.' + last_path + # Get all installed/imported factories + factories_list = all_subclasses(factory.django.DjangoModelFactory) + # Find the factory that matches the provided model + for potential_factory in factories_list: + # Fetch the model for the factory + factory_model = potential_factory._meta.model + # Check if the factories model matches the provided model + if f"{factory_model.__module__}.{factory_model.__name__}" == provided_model: + # Now that we have the right factory, we can build according to the provided custom attributes + field_customizations = options.get('field_customizations', {}) + base_node = Node(factory_model.__name__) + base_node.factory = potential_factory + # For each provided custom attribute... + for field, value in field_customizations.items(): + + # We need to build a tree of objects to be created and may be customized by other custom attributes + stripped_field = field.strip("--") + fk_field_customization_split = stripped_field.split("__") + base_node = build_tree_from_field_list( + fk_field_customization_split, + potential_factory, + base_node, + value, + ) + + built_node = base_node.build_records() + log.info(f"\nGenerated factory data: \n{base_node}") + return str(list(built_node.values())[0].pk) + + log.error(f"Provided model: {provided_model} does not exist or does not have an associated factory") + raise CommandError(f"Provided model: {provided_model}'s factory is not imported or does not exist") 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/settings.py b/edx_arch_experiments/tests/settings.py new file mode 100644 index 0000000..aa9eb61 --- /dev/null +++ b/edx_arch_experiments/tests/settings.py @@ -0,0 +1,23 @@ +""" +These settings are here to use during manufacture_data tests + +In a real-world use case, apps in this project are installed into other +Django applications, so these settings will not be used. +""" +import os + +INSTALLED_APPS = [ + 'edx_arch_experiments', + 'edx_arch_experiments.tests.test_management', +] + +DATABASES = { + 'default': { + 'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'), + 'NAME': os.environ.get('DB_NAME', 'default.db'), + 'USER': os.environ.get('DB_USER', ''), + 'PASSWORD': os.environ.get('DB_PASSWORD', ''), + 'HOST': os.environ.get('DB_HOST', ''), + 'PORT': os.environ.get('DB_PORT', ''), + }, +} diff --git a/edx_arch_experiments/tests/test_management/__init__.py b/edx_arch_experiments/tests/test_management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edx_arch_experiments/tests/test_management/factories.py b/edx_arch_experiments/tests/test_management/factories.py new file mode 100644 index 0000000..4746959 --- /dev/null +++ b/edx_arch_experiments/tests/test_management/factories.py @@ -0,0 +1,29 @@ +""" +Factories for models used in testing manufacture_data command +""" + +import factory + +from edx_arch_experiments.tests.test_management.models import TestPerson, TestPersonContactInfo + + +class TestPersonFactory(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 + + test_person = factory.SubFactory(TestPersonFactory) + address = '123 4th st, Fiveville, AZ, 67890' diff --git a/edx_arch_experiments/tests/test_management/models.py b/edx_arch_experiments/tests/test_management/models.py new file mode 100644 index 0000000..4e14848 --- /dev/null +++ b/edx_arch_experiments/tests/test_management/models.py @@ -0,0 +1,14 @@ +""" +Models used in testing manufacture_data command +""" +from django.db import models + + +class TestPerson(models.Model): + first_name = models.CharField(max_length=30) + last_name = models.CharField(max_length=30) + + +class TestPersonContactInfo(models.Model): + test_person = models.ForeignKey(TestPerson, on_delete=models.CASCADE) + address = models.CharField(max_length=100) diff --git a/edx_arch_experiments/tests/test_management/test_management.py b/edx_arch_experiments/tests/test_management/test_management.py new file mode 100644 index 0000000..2408fd5 --- /dev/null +++ b/edx_arch_experiments/tests/test_management/test_management.py @@ -0,0 +1,209 @@ +""" +Test management commands and related functions. +""" + +from argparse import _AppendConstAction, _CountAction, _StoreConstAction, _SubParsersAction + +from django.core.management import get_commands, load_command_class +from django.core.management.base import BaseCommand, CommandError +from django.test import TestCase +from pytest import mark + +# pylint: disable=unused-import +from edx_arch_experiments.tests.test_management.factories import TestPersonContactInfoFactory, TestPersonFactory +from edx_arch_experiments.tests.test_management.models import TestPerson, TestPersonContactInfo + + +# 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') + + 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='edx_arch_experiments.tests.test_management.models.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='edx_arch_experiments.tests.test_management.models.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='edx_arch_experiments.tests.test_management.models.TestPersonContactInfo', + field_customizations={'test_person__last_name': 'Nowhere'}, + ) + assert TestPerson.objects.all().count() == 1 + assert TestPersonContactInfo.objects.all().count() == 1 + assert TestPersonContactInfo.objects.filter( + pk=created_object + ).first().test_person.last_name == 'Nowhere' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f6abc77 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,42 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile +# +asgiref==3.7.2 + # via django +backports-zoneinfo==0.2.1 + # via django +cffi==1.15.1 + # via pynacl +click==8.1.7 + # via edx-django-utils +django==4.2.4 + # via + # django-crum + # django-waffle + # edx-arch-experiments (setup.py) + # edx-django-utils +django-crum==0.7.9 + # via edx-django-utils +django-waffle==4.0.0 + # via edx-django-utils +edx-django-utils==5.7.0 + # via edx-arch-experiments (setup.py) +newrelic==8.11.0 + # via edx-django-utils +pbr==5.11.1 + # via stevedore +psutil==5.9.5 + # via edx-django-utils +pycparser==2.21 + # via cffi +pynacl==1.5.0 + # via edx-django-utils +sqlparse==0.4.4 + # via django +stevedore==5.1.0 + # via edx-django-utils +typing-extensions==4.7.1 + # via asgiref diff --git a/requirements/dev.txt b/requirements/dev.txt index 6e23efc..a4e4057 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -57,6 +57,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 @@ -97,6 +99,12 @@ exceptiongroup==1.1.3 # via # -r requirements/quality.txt # pytest +factory-boy==3.3.0 + # via -r requirements/quality.txt +faker==19.6.1 + # via + # -r requirements/quality.txt + # factory-boy filelock==3.12.3 # via # -r requirements/ci.txt @@ -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 @@ -312,6 +324,7 @@ six==1.16.0 # -r requirements/ci.txt # -r requirements/quality.txt # edx-lint + # python-dateutil # tox snowballstemmer==2.2.0 # via @@ -361,6 +374,7 @@ typing-extensions==4.7.1 # -r requirements/quality.txt # asgiref # astroid + # faker # filelock # pylint # rich diff --git a/requirements/doc.in b/requirements/doc.in index 690e8e1..2e981b1 100644 --- a/requirements/doc.in +++ b/requirements/doc.in @@ -7,3 +7,5 @@ doc8 # reStructuredText style checker edx_sphinx_theme # edX theme for Sphinx output readme_renderer # Validates README.rst for usage on PyPI Sphinx # Documentation builder +factory-boy +pytest #Needed? \ No newline at end of file diff --git a/requirements/doc.txt b/requirements/doc.txt index f9aa8e1..8dedbb4 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -31,6 +31,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 @@ -62,6 +64,14 @@ exceptiongroup==1.1.3 # via # -r requirements/test.txt # pytest +factory-boy==3.3.0 + # via + # -r requirements/doc.in + # -r requirements/test.txt +faker==19.6.1 + # via + # -r requirements/test.txt + # factory-boy idna==3.4 # via requests imagesize==1.4.1 @@ -119,6 +129,7 @@ pynacl==1.5.0 # edx-django-utils pytest==7.4.2 # via + # -r requirements/doc.in # -r requirements/test.txt # pytest-cov # pytest-django @@ -126,6 +137,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 @@ -146,7 +161,10 @@ requests==2.31.0 restructuredtext-lint==1.4.0 # via doc8 six==1.16.0 - # via edx-sphinx-theme + # via + # -r requirements/test.txt + # edx-sphinx-theme + # python-dateutil snowballstemmer==2.2.0 # via sphinx sphinx==5.3.0 @@ -189,6 +207,7 @@ typing-extensions==4.7.1 # via # -r requirements/test.txt # asgiref + # faker urllib3==2.0.4 # via requests zipp==3.16.2 diff --git a/requirements/pip.txt b/requirements/pip.txt index da0741c..3e7d8f4 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.2.0 +setuptools==68.2.2 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index 6a28842..f276071 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -40,6 +40,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 @@ -67,6 +69,12 @@ exceptiongroup==1.1.3 # via # -r requirements/test.txt # pytest +factory-boy==3.3.0 + # via -r requirements/test.txt +faker==19.6.1 + # via + # -r requirements/test.txt + # factory-boy idna==3.4 # via requests importlib-metadata==6.8.0 @@ -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 @@ -201,7 +213,10 @@ rich==13.5.2 secretstorage==3.3.3 # via keyring six==1.16.0 - # via edx-lint + # via + # -r requirements/test.txt + # edx-lint + # python-dateutil snowballstemmer==2.2.0 # via pydocstyle sqlparse==0.4.4 @@ -232,6 +247,7 @@ typing-extensions==4.7.1 # -r requirements/test.txt # asgiref # astroid + # faker # pylint # rich urllib3==2.0.4 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 8cbd360..c7d50b7 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.6.1 + # via factory-boy iniconfig==2.0.0 # via pytest jinja2==3.1.2 @@ -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/tox.ini b/tox.ini index 2887488..cf91fb7 100644 --- a/tox.ini +++ b/tox.ini @@ -31,7 +31,7 @@ match-dir = (?!migrations) [pytest] DJANGO_SETTINGS_MODULE = test_settings -addopts = --cov edx_arch_experiments --cov-report term-missing --cov-report xml +addopts = --cov edx_arch_experiments --cov-report term-missing --cov-report xml --ds=edx_arch_experiments.tests.settings norecursedirs = .* requirements site-packages [testenv]