From 80e816127d4b0d2a2e059cfe1f14b79604ac024c Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Wed, 30 Aug 2023 15:42:26 +0000 Subject: [PATCH 1/5] feat: Add manufacture_data django command --- CHANGELOG.rst | 5 + edx_arch_experiments/__init__.py | 2 +- edx_arch_experiments/management/__init__.py | 0 .../management/commands/__init__.py | 1 + .../management/commands/manufacture_data.py | 326 ++++++++++++++++++ requirements.txt | 42 +++ requirements/base.txt | 9 +- requirements/ci.txt | 6 +- requirements/dev.txt | 57 +-- requirements/doc.in | 2 + requirements/doc.txt | 32 +- requirements/pip-tools.txt | 7 +- requirements/pip.txt | 6 +- requirements/quality.txt | 41 +-- requirements/scripts.txt | 12 +- requirements/test.txt | 15 +- 16 files changed, 478 insertions(+), 85 deletions(-) create mode 100644 edx_arch_experiments/management/__init__.py create mode 100644 edx_arch_experiments/management/commands/__init__.py create mode 100644 edx_arch_experiments/management/commands/manufacture_data.py create mode 100644 requirements.txt 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..0be19bf --- /dev/null +++ b/edx_arch_experiments/management/commands/__init__.py @@ -0,0 +1 @@ +__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 new file mode 100644 index 0000000..365e824 --- /dev/null +++ b/edx_arch_experiments/management/commands/manufacture_data.py @@ -0,0 +1,326 @@ +""" +Management command for making things with test factories +""" + +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 + +# 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 + +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 ManufactureDataCommand(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/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/base.txt b/requirements/base.txt index e0f4787..7ecba47 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,21 +8,22 @@ asgiref==3.7.2 # via django cffi==1.15.1 # via pynacl -click==8.1.5 +click==8.1.7 # via edx-django-utils django==3.2.20 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in # django-crum + # django-waffle # edx-django-utils django-crum==0.7.9 # via edx-django-utils -django-waffle==3.0.0 +django-waffle==4.0.0 # via edx-django-utils -edx-django-utils==5.5.0 +edx-django-utils==5.7.0 # via -r requirements/base.in -newrelic==8.8.1 +newrelic==8.11.0 # via edx-django-utils pbr==5.11.1 # via stevedore diff --git a/requirements/ci.txt b/requirements/ci.txt index 7c42abb..c3aa971 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -4,7 +4,7 @@ # # make upgrade # -distlib==0.3.6 +distlib==0.3.7 # via virtualenv filelock==3.12.2 # via @@ -12,7 +12,7 @@ filelock==3.12.2 # virtualenv packaging==23.1 # via tox -platformdirs==3.9.1 +platformdirs==3.10.0 # via virtualenv pluggy==1.2.0 # via tox @@ -26,5 +26,5 @@ tox==3.28.0 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/ci.in -virtualenv==20.24.0 +virtualenv==20.24.3 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index 16e0d9d..3fe21e5 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -21,7 +21,7 @@ build==0.10.0 # via # -r requirements/pip-tools.txt # pip-tools -certifi==2023.5.7 +certifi==2023.7.22 # via # -r requirements/quality.txt # requests @@ -30,13 +30,13 @@ cffi==1.15.1 # -r requirements/quality.txt # cryptography # pynacl -chardet==5.1.0 +chardet==5.2.0 # via diff-cover charset-normalizer==3.2.0 # via # -r requirements/quality.txt # requests -click==8.1.5 +click==8.1.7 # via # -r requirements/pip-tools.txt # -r requirements/quality.txt @@ -49,25 +49,25 @@ click-log==0.4.0 # via # -r requirements/quality.txt # edx-lint -code-annotations==1.3.0 +code-annotations==1.5.0 # via # -r requirements/quality.txt # edx-lint -coverage[toml]==7.2.7 +coverage[toml]==7.3.0 # via # -r requirements/quality.txt # pytest-cov -cryptography==41.0.2 +cryptography==41.0.3 # via # -r requirements/quality.txt # secretstorage diff-cover==7.7.0 # via -r requirements/dev.in -dill==0.3.6 +dill==0.3.7 # via # -r requirements/quality.txt # pylint -distlib==0.3.6 +distlib==0.3.7 # via # -r requirements/ci.txt # virtualenv @@ -76,13 +76,14 @@ django==3.2.20 # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt # django-crum + # django-waffle # edx-django-utils # edx-i18n-tools django-crum==0.7.9 # via # -r requirements/quality.txt # edx-django-utils -django-waffle==3.0.0 +django-waffle==4.0.0 # via # -r requirements/quality.txt # edx-django-utils @@ -90,13 +91,13 @@ docutils==0.20.1 # via # -r requirements/quality.txt # readme-renderer -edx-django-utils==5.5.0 +edx-django-utils==5.7.0 # via -r requirements/quality.txt -edx-i18n-tools==1.0.0 +edx-i18n-tools==1.1.0 # via -r requirements/dev.in edx-lint==5.3.4 # via -r requirements/quality.txt -exceptiongroup==1.1.2 +exceptiongroup==1.1.3 # via # -r requirements/quality.txt # pytest @@ -114,7 +115,7 @@ importlib-metadata==6.8.0 # -r requirements/quality.txt # keyring # twine -importlib-resources==6.0.0 +importlib-resources==6.0.1 # via # -r requirements/quality.txt # keyring @@ -164,11 +165,11 @@ mdurl==0.1.2 # via # -r requirements/quality.txt # markdown-it-py -more-itertools==9.1.0 +more-itertools==10.1.0 # via # -r requirements/quality.txt # jaraco-classes -newrelic==8.8.1 +newrelic==8.11.0 # via # -r requirements/quality.txt # edx-django-utils @@ -186,13 +187,13 @@ pbr==5.11.1 # via # -r requirements/quality.txt # stevedore -pip-tools==7.0.0 +pip-tools==7.3.0 # via -r requirements/pip-tools.txt pkginfo==1.9.6 # via # -r requirements/quality.txt # twine -platformdirs==3.9.1 +platformdirs==3.10.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -215,7 +216,7 @@ py==1.11.0 # via # -r requirements/ci.txt # tox -pycodestyle==2.10.0 +pycodestyle==2.11.0 # via -r requirements/quality.txt pycparser==2.21 # via @@ -223,13 +224,13 @@ pycparser==2.21 # cffi pydocstyle==6.3.0 # via -r requirements/quality.txt -pygments==2.15.1 +pygments==2.16.1 # via # -r requirements/quality.txt # diff-cover # readme-renderer # rich -pylint==2.17.4 +pylint==2.17.5 # via # -r requirements/quality.txt # edx-lint @@ -274,12 +275,12 @@ pytz==2023.3 # via # -r requirements/quality.txt # django -pyyaml==6.0 +pyyaml==6.0.1 # via # -r requirements/quality.txt # code-annotations # edx-i18n-tools -readme-renderer==40.0 +readme-renderer==41.0 # via # -r requirements/quality.txt # twine @@ -296,7 +297,7 @@ rfc3986==2.0.0 # via # -r requirements/quality.txt # twine -rich==13.4.2 +rich==13.5.2 # via # -r requirements/quality.txt # twine @@ -340,7 +341,7 @@ tomli==2.0.1 # pyproject-hooks # pytest # tox -tomlkit==0.11.8 +tomlkit==0.12.1 # via # -r requirements/quality.txt # pylint @@ -349,7 +350,7 @@ tox==3.28.0 # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/ci.txt # tox-battery -tox-battery==0.6.1 +tox-battery==0.6.2 # via -r requirements/dev.in twine==4.0.2 # via -r requirements/quality.txt @@ -360,12 +361,12 @@ typing-extensions==4.7.1 # astroid # pylint # rich -urllib3==2.0.3 +urllib3==2.0.4 # via # -r requirements/quality.txt # requests # twine -virtualenv==20.24.0 +virtualenv==20.24.3 # via # -r requirements/ci.txt # tox @@ -373,7 +374,7 @@ webencodings==0.5.1 # via # -r requirements/quality.txt # bleach -wheel==0.40.0 +wheel==0.41.2 # via # -r requirements/pip-tools.txt # pip-tools 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 7bcbda3..6b6c671 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -14,7 +14,7 @@ babel==2.12.1 # via sphinx bleach==6.0.0 # via readme-renderer -certifi==2023.5.7 +certifi==2023.7.22 # via requests cffi==1.15.1 # via @@ -22,14 +22,14 @@ cffi==1.15.1 # pynacl charset-normalizer==3.2.0 # via requests -click==8.1.5 +click==8.1.7 # via # -r requirements/test.txt # code-annotations # edx-django-utils -code-annotations==1.3.0 +code-annotations==1.5.0 # via -r requirements/test.txt -coverage[toml]==7.2.7 +coverage[toml]==7.3.0 # via # -r requirements/test.txt # pytest-cov @@ -38,12 +38,13 @@ django==3.2.20 # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # django-crum + # django-waffle # edx-django-utils django-crum==0.7.9 # via # -r requirements/test.txt # edx-django-utils -django-waffle==3.0.0 +django-waffle==4.0.0 # via # -r requirements/test.txt # edx-django-utils @@ -55,14 +56,18 @@ docutils==0.19 # readme-renderer # restructuredtext-lint # sphinx -edx-django-utils==5.5.0 +edx-django-utils==5.7.0 # via -r requirements/test.txt edx-sphinx-theme==3.1.0 # via -r requirements/doc.in -exceptiongroup==1.1.2 +exceptiongroup==1.1.3 # via # -r requirements/test.txt # pytest +factory-boy==3.3.0 + # via -r requirements/doc.in +faker==19.3.1 + # via factory-boy idna==3.4 # via requests imagesize==1.4.1 @@ -82,7 +87,7 @@ markupsafe==2.1.3 # via # -r requirements/test.txt # jinja2 -newrelic==8.8.1 +newrelic==8.11.0 # via # -r requirements/test.txt # edx-django-utils @@ -107,7 +112,7 @@ pycparser==2.21 # via # -r requirements/test.txt # cffi -pygments==2.15.1 +pygments==2.16.1 # via # doc8 # readme-renderer @@ -118,6 +123,7 @@ pynacl==1.5.0 # edx-django-utils pytest==7.4.0 # via + # -r requirements/doc.in # -r requirements/test.txt # pytest-cov # pytest-django @@ -125,6 +131,8 @@ 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 faker python-slugify==8.0.1 # via # -r requirements/test.txt @@ -138,7 +146,7 @@ pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations -readme-renderer==40.0 +readme-renderer==41.0 # via -r requirements/doc.in requests==2.31.0 # via sphinx @@ -148,6 +156,7 @@ six==1.16.0 # via # bleach # edx-sphinx-theme + # python-dateutil snowballstemmer==2.2.0 # via sphinx sphinx==5.3.0 @@ -190,7 +199,8 @@ typing-extensions==4.7.1 # via # -r requirements/test.txt # asgiref -urllib3==2.0.3 + # faker +urllib3==2.0.4 # via requests webencodings==0.5.1 # via bleach diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 5a27e2a..007ed38 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -6,11 +6,11 @@ # build==0.10.0 # via pip-tools -click==8.1.5 +click==8.1.7 # via pip-tools packaging==23.1 # via build -pip-tools==7.0.0 +pip-tools==7.3.0 # via -r requirements/pip-tools.in pyproject-hooks==1.0.0 # via build @@ -18,7 +18,8 @@ tomli==2.0.1 # via # build # pip-tools -wheel==0.40.0 + # pyproject-hooks +wheel==0.41.2 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/pip.txt b/requirements/pip.txt index bd9fb55..13c7e84 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -4,11 +4,11 @@ # # make upgrade # -wheel==0.40.0 +wheel==0.41.2 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==23.2 +pip==23.2.1 # via -r requirements/pip.in -setuptools==68.0.0 +setuptools==68.1.2 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index 999fb4a..3761f74 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -14,7 +14,7 @@ astroid==2.15.6 # pylint-celery bleach==6.0.0 # via readme-renderer -certifi==2023.5.7 +certifi==2023.7.22 # via requests cffi==1.15.1 # via @@ -23,7 +23,7 @@ cffi==1.15.1 # pynacl charset-normalizer==3.2.0 # via requests -click==8.1.5 +click==8.1.7 # via # -r requirements/test.txt # click-log @@ -32,39 +32,40 @@ click==8.1.5 # edx-lint click-log==0.4.0 # via edx-lint -code-annotations==1.3.0 +code-annotations==1.5.0 # via # -r requirements/test.txt # edx-lint -coverage[toml]==7.2.7 +coverage[toml]==7.3.0 # via # -r requirements/test.txt # pytest-cov -cryptography==41.0.2 +cryptography==41.0.3 # via secretstorage -dill==0.3.6 +dill==0.3.7 # via pylint django==3.2.20 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # django-crum + # django-waffle # edx-django-utils django-crum==0.7.9 # via # -r requirements/test.txt # edx-django-utils -django-waffle==3.0.0 +django-waffle==4.0.0 # via # -r requirements/test.txt # edx-django-utils docutils==0.20.1 # via readme-renderer -edx-django-utils==5.5.0 +edx-django-utils==5.7.0 # via -r requirements/test.txt edx-lint==5.3.4 # via -r requirements/quality.in -exceptiongroup==1.1.2 +exceptiongroup==1.1.3 # via # -r requirements/test.txt # pytest @@ -74,7 +75,7 @@ importlib-metadata==6.8.0 # via # keyring # twine -importlib-resources==6.0.0 +importlib-resources==6.0.1 # via keyring iniconfig==2.0.0 # via @@ -108,9 +109,9 @@ mccabe==0.7.0 # via pylint mdurl==0.1.2 # via markdown-it-py -more-itertools==9.1.0 +more-itertools==10.1.0 # via jaraco-classes -newrelic==8.8.1 +newrelic==8.11.0 # via # -r requirements/test.txt # edx-django-utils @@ -124,7 +125,7 @@ pbr==5.11.1 # stevedore pkginfo==1.9.6 # via twine -platformdirs==3.9.1 +platformdirs==3.10.0 # via pylint pluggy==1.2.0 # via @@ -134,7 +135,7 @@ psutil==5.9.5 # via # -r requirements/test.txt # edx-django-utils -pycodestyle==2.10.0 +pycodestyle==2.11.0 # via -r requirements/quality.in pycparser==2.21 # via @@ -142,11 +143,11 @@ pycparser==2.21 # cffi pydocstyle==6.3.0 # via -r requirements/quality.in -pygments==2.15.1 +pygments==2.16.1 # via # readme-renderer # rich -pylint==2.17.4 +pylint==2.17.5 # via # edx-lint # pylint-celery @@ -185,7 +186,7 @@ pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations -readme-renderer==40.0 +readme-renderer==41.0 # via twine requests==2.31.0 # via @@ -195,7 +196,7 @@ requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine -rich==13.4.2 +rich==13.5.2 # via twine secretstorage==3.3.3 # via keyring @@ -224,7 +225,7 @@ tomli==2.0.1 # coverage # pylint # pytest -tomlkit==0.11.8 +tomlkit==0.12.1 # via pylint twine==4.0.2 # via -r requirements/quality.in @@ -235,7 +236,7 @@ typing-extensions==4.7.1 # astroid # pylint # rich -urllib3==2.0.3 +urllib3==2.0.4 # via # requests # twine diff --git a/requirements/scripts.txt b/requirements/scripts.txt index fd6705c..15cae8b 100644 --- a/requirements/scripts.txt +++ b/requirements/scripts.txt @@ -20,7 +20,7 @@ cffi==1.15.1 # pynacl charset-normalizer==3.2.0 # via requests -click==8.1.5 +click==8.1.7 # via # -r requirements/base.txt # code-annotations @@ -34,6 +34,7 @@ django==3.2.20 # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt # django-crum + # django-waffle # edx-django-utils # edx-event-bus-kafka # edx-toggles @@ -43,19 +44,19 @@ django-crum==0.7.9 # -r requirements/base.txt # edx-django-utils # edx-toggles -django-waffle==3.0.0 +django-waffle==4.0.0 # via # -r requirements/base.txt # edx-django-utils # edx-toggles -edx-django-utils==5.5.0 +edx-django-utils==5.7.0 # via # -r requirements/base.txt # edx-event-bus-kafka # edx-toggles edx-event-bus-kafka==5.3.1 # via -r requirements/scripts.in -edx-opaque-keys[django]==2.4.0 +edx-opaque-keys[django]==2.5.0 # via openedx-events edx-toggles==5.1.0 # via edx-event-bus-kafka @@ -69,7 +70,7 @@ jinja2==3.1.2 # via code-annotations markupsafe==2.1.3 # via jinja2 -newrelic==8.8.1 +newrelic==8.11.0 # via # -r requirements/base.txt # edx-django-utils @@ -119,5 +120,6 @@ typing-extensions==4.7.1 # via # -r requirements/base.txt # asgiref + # edx-opaque-keys urllib3==2.0.4 # via requests diff --git a/requirements/test.txt b/requirements/test.txt index 35a299e..df2c39c 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -12,31 +12,32 @@ cffi==1.15.1 # via # -r requirements/base.txt # pynacl -click==8.1.5 +click==8.1.7 # via # -r requirements/base.txt # code-annotations # edx-django-utils -code-annotations==1.3.0 +code-annotations==1.5.0 # via -r requirements/test.in -coverage[toml]==7.2.7 +coverage[toml]==7.3.0 # via pytest-cov # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt # django-crum + # django-waffle # edx-django-utils django-crum==0.7.9 # via # -r requirements/base.txt # edx-django-utils -django-waffle==3.0.0 +django-waffle==4.0.0 # via # -r requirements/base.txt # edx-django-utils -edx-django-utils==5.5.0 +edx-django-utils==5.7.0 # via -r requirements/base.txt -exceptiongroup==1.1.2 +exceptiongroup==1.1.3 # via pytest iniconfig==2.0.0 # via pytest @@ -44,7 +45,7 @@ jinja2==3.1.2 # via code-annotations markupsafe==2.1.3 # via jinja2 -newrelic==8.8.1 +newrelic==8.11.0 # via # -r requirements/base.txt # edx-django-utils From 2cdb732b83d78c784c9e26c35c0f36a5ae0eccd6 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Mon, 11 Sep 2023 21:56:19 +0000 Subject: [PATCH 2/5] chore: unit tests --- .../management/commands/__init__.py | 1 - .../management/commands/manufacture_data.py | 14 +- edx_arch_experiments/tests/__init__.py | 0 .../tests/management/__init__.py | 0 .../tests/management/test_management.py | 239 ++++++++++++++++++ requirements/dev.txt | 18 +- requirements/doc.txt | 19 +- requirements/pip-tools.txt | 2 +- requirements/pip.txt | 2 +- requirements/quality.txt | 17 +- requirements/scripts.txt | 2 +- requirements/test.in | 2 + requirements/test.txt | 13 +- test_settings.py | 1 + 14 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 edx_arch_experiments/tests/__init__.py create mode 100644 edx_arch_experiments/tests/management/__init__.py create mode 100644 edx_arch_experiments/tests/management/test_management.py 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 = [ From a3d81dcc9270b6bbf51d8b893df4bd7a53e7f342 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Tue, 12 Sep 2023 13:51:24 +0000 Subject: [PATCH 3/5] fix: tweaking the testbed to support what we wanna be doing --- edx_arch_experiments/tests/settings.py | 18 ++++++++ .../__init__.py | 0 .../tests/test_management/factories.py | 23 ++++++++++ .../tests/test_management/models.py | 10 +++++ .../test_management.py | 43 +++---------------- tox.ini | 2 +- 6 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 edx_arch_experiments/tests/settings.py rename edx_arch_experiments/tests/{management => test_management}/__init__.py (100%) create mode 100644 edx_arch_experiments/tests/test_management/factories.py create mode 100644 edx_arch_experiments/tests/test_management/models.py rename edx_arch_experiments/tests/{management => test_management}/test_management.py (86%) diff --git a/edx_arch_experiments/tests/settings.py b/edx_arch_experiments/tests/settings.py new file mode 100644 index 0000000..a7cf893 --- /dev/null +++ b/edx_arch_experiments/tests/settings.py @@ -0,0 +1,18 @@ +import os +from edx_arch_experiments.settings import * # noqa + +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', ''), + }, +} \ No newline at end of file diff --git a/edx_arch_experiments/tests/management/__init__.py b/edx_arch_experiments/tests/test_management/__init__.py similarity index 100% rename from edx_arch_experiments/tests/management/__init__.py rename to edx_arch_experiments/tests/test_management/__init__.py 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..83e01b8 --- /dev/null +++ b/edx_arch_experiments/tests/test_management/factories.py @@ -0,0 +1,23 @@ +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 + + address = '123 4th st, Fiveville, AZ, 67890' + command = 'manufacture_data' \ No newline at end of file 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..7459e6c --- /dev/null +++ b/edx_arch_experiments/tests/test_management/models.py @@ -0,0 +1,10 @@ +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): + person = models.ForeignKey(TestPerson, on_delete=models.CASCADE) + address = models.CharField(max_length=100) \ No newline at end of file diff --git a/edx_arch_experiments/tests/management/test_management.py b/edx_arch_experiments/tests/test_management/test_management.py similarity index 86% rename from edx_arch_experiments/tests/management/test_management.py rename to edx_arch_experiments/tests/test_management/test_management.py index 5349928..7fd07bf 100644 --- a/edx_arch_experiments/tests/management/test_management.py +++ b/edx_arch_experiments/tests/test_management/test_management.py @@ -5,13 +5,12 @@ 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 +from edx_arch_experiments.tests.test_management.models import TestPerson, TestPersonContactInfo +from edx_arch_experiments.tests.test_management.factories import TestPersonFactory, TestPersonContactInfoFactory # Copied from django.core.management.__init__.py @@ -154,45 +153,15 @@ def test_command_requires_valid_model(self): with self.assertRaises(CommandError): call_command(self.command, model='FakeModel') - @isolate_apps("edx_arch_experiments") + # @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') + 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() @@ -214,7 +183,7 @@ class Meta: assert TestPerson.objects.all().count() == 0 created_object = call_command( self.command, - model='TestPerson', + model='edx_arch_experiments.tests.test_management.models.TestPerson', field_customizations={'first_name': 'Steve'}, ) assert TestPerson.objects.all().count() == 1 @@ -229,7 +198,7 @@ class Meta: assert TestPersonContactInfo.objects.all().count() == 0 created_object = call_command( self.command, - model='TestPersonContactInfo', + model='edx_arch_experiments.tests.test_management.models.TestPersonContactInfo', field_customizations={'person__last_name': 'Nowhere'}, ) assert TestPerson.objects.all().count() == 1 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] From efaf450b541e8eb2e7c70ae309c14025e9e21cf6 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Tue, 12 Sep 2023 20:35:22 +0000 Subject: [PATCH 4/5] chore: fix test/lint --- .../management/commands/manufacture_data.py | 2 -- edx_arch_experiments/tests/settings.py | 9 +++++-- .../tests/test_management/factories.py | 8 +++++- .../tests/test_management/models.py | 8 ++++-- .../tests/test_management/test_management.py | 25 ++++++++++--------- test_settings.py | 1 - 6 files changed, 33 insertions(+), 20 deletions(-) diff --git a/edx_arch_experiments/management/commands/manufacture_data.py b/edx_arch_experiments/management/commands/manufacture_data.py index 686d134..0ae4f8f 100644 --- a/edx_arch_experiments/management/commands/manufacture_data.py +++ b/edx_arch_experiments/management/commands/manufacture_data.py @@ -12,8 +12,6 @@ from django.db import connections from factory.declarations import SubFactory -# TODO: Document usage - log = logging.getLogger(__name__) diff --git a/edx_arch_experiments/tests/settings.py b/edx_arch_experiments/tests/settings.py index a7cf893..aa9eb61 100644 --- a/edx_arch_experiments/tests/settings.py +++ b/edx_arch_experiments/tests/settings.py @@ -1,5 +1,10 @@ +""" +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 -from edx_arch_experiments.settings import * # noqa INSTALLED_APPS = [ 'edx_arch_experiments', @@ -15,4 +20,4 @@ 'HOST': os.environ.get('DB_HOST', ''), 'PORT': os.environ.get('DB_PORT', ''), }, -} \ No newline at end of file +} diff --git a/edx_arch_experiments/tests/test_management/factories.py b/edx_arch_experiments/tests/test_management/factories.py index 83e01b8..4746959 100644 --- a/edx_arch_experiments/tests/test_management/factories.py +++ b/edx_arch_experiments/tests/test_management/factories.py @@ -1,6 +1,12 @@ +""" +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 @@ -19,5 +25,5 @@ class TestPersonContactInfoFactory(factory.django.DjangoModelFactory): class Meta: model = TestPersonContactInfo + test_person = factory.SubFactory(TestPersonFactory) address = '123 4th st, Fiveville, AZ, 67890' - command = 'manufacture_data' \ No newline at end of file diff --git a/edx_arch_experiments/tests/test_management/models.py b/edx_arch_experiments/tests/test_management/models.py index 7459e6c..4e14848 100644 --- a/edx_arch_experiments/tests/test_management/models.py +++ b/edx_arch_experiments/tests/test_management/models.py @@ -1,10 +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): - person = models.ForeignKey(TestPerson, on_delete=models.CASCADE) - address = models.CharField(max_length=100) \ No newline at end of file + 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 index 7fd07bf..2408fd5 100644 --- a/edx_arch_experiments/tests/test_management/test_management.py +++ b/edx_arch_experiments/tests/test_management/test_management.py @@ -3,14 +3,15 @@ """ from argparse import _AppendConstAction, _CountAction, _StoreConstAction, _SubParsersAction -from pytest import mark 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 -from edx_arch_experiments.tests.test_management.factories import TestPersonFactory, TestPersonContactInfoFactory # Copied from django.core.management.__init__.py @@ -152,20 +153,20 @@ def test_command_requires_valid_model(self): """ with self.assertRaises(CommandError): call_command(self.command, model='FakeModel') - - # @isolate_apps("edx_arch_experiments") - def test_model_definition(self): - # def test_single_object_create_no_customizations(self): + 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') + 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): + def test_command_requires_valid_field(self): """ Test that the manufacture_data command will raise an error if the provided field is invalid. """ @@ -176,7 +177,7 @@ def test_model_definition(self): field_customizations={"fake_field": 'fake_value'} ) - # def test_command_can_customize_fields(self): + def test_command_can_customize_fields(self): """ Test that the manufacture_data command will create a single object with customizations. """ @@ -190,7 +191,7 @@ def test_model_definition(self): 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): + def test_command_can_customize_nested_objects(self): """ Test that the manufacture_data command supports customizing nested objects. """ @@ -199,10 +200,10 @@ def test_model_definition(self): created_object = call_command( self.command, model='edx_arch_experiments.tests.test_management.models.TestPersonContactInfo', - field_customizations={'person__last_name': 'Nowhere'}, + 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().person.last_name == 'Nowhere' + ).first().test_person.last_name == 'Nowhere' diff --git a/test_settings.py b/test_settings.py index 6f68939..493311f 100644 --- a/test_settings.py +++ b/test_settings.py @@ -33,7 +33,6 @@ def root(*args): 'django.contrib.messages', 'django.contrib.sessions', 'edx_arch_experiments', - # 'tests.management' ) LOCALE_PATHS = [ From f113562f0b5d964842898eac2f235b6d254eabe4 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Wed, 13 Sep 2023 19:44:17 +0000 Subject: [PATCH 5/5] docs: Add usage documentation for manufacture_data --- .../management/commands/manufacture_data.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/edx_arch_experiments/management/commands/manufacture_data.py b/edx_arch_experiments/management/commands/manufacture_data.py index 0ae4f8f..ea5b289 100644 --- a/edx_arch_experiments/management/commands/manufacture_data.py +++ b/edx_arch_experiments/management/commands/manufacture_data.py @@ -1,5 +1,60 @@ """ 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