From 8d96e01d4ddb81c2b5a8e9a32ee9de639e7fe72f Mon Sep 17 00:00:00 2001 From: Paul Schilling Date: Wed, 15 May 2024 11:00:59 +0200 Subject: [PATCH] [#8] Integrate cration of docs with config steps + other feedback - delete registry, store config_settings as class_attributes on configuration steps - add static method to update model field descriptions with custom fields - modify template to show required settings section only if required settings are present --- .github/workflows/ci.yml | 4 +- django_setup_configuration/base.py | 72 +++++++++---- django_setup_configuration/configuration.py | 9 +- django_setup_configuration/constants.py | 7 +- .../management/commands/check_config_docs.py | 43 -------- .../commands/generate_config_docs.py | 100 ++++++++---------- django_setup_configuration/registry.py | 18 ++-- .../django_setup_configuration/config_doc.rst | 48 +++++++++ setup.cfg | 2 +- testapp/config_models.py | 34 ------ testapp/configuration.py | 21 ++-- testapp/docs/configuration/product.rst | 55 ---------- testapp/settings.py | 10 +- testapp/templates/testapp/config_doc.rst | 12 +-- tests/mocks.py | 4 +- tests/test_config_docs.py | 65 ++++++------ tests/test_config_registry.py | 46 -------- 17 files changed, 223 insertions(+), 327 deletions(-) delete mode 100644 django_setup_configuration/management/commands/check_config_docs.py create mode 100644 django_setup_configuration/templates/django_setup_configuration/config_doc.rst delete mode 100644 testapp/config_models.py delete mode 100644 testapp/docs/configuration/product.rst delete mode 100644 tests/test_config_registry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8828c35..128a209 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,8 +48,8 @@ jobs: env: PYTHON_VERSION: ${{ matrix.python }} DJANGO: ${{ matrix.django }} - POSTGRES_USER: postgres - POSTGRES_HOST: localhost + DB_USER: postgres + DB_HOST: localhost - name: Publish coverage report uses: codecov/codecov-action@v3 diff --git a/django_setup_configuration/base.py b/django_setup_configuration/base.py index 1b64271..bc924b4 100644 --- a/django_setup_configuration/base.py +++ b/django_setup_configuration/base.py @@ -1,13 +1,16 @@ from dataclasses import dataclass, field -from typing import Iterator, Mapping, Sequence +from typing import Iterator, Mapping, Sequence, Type +from django.conf import settings from django.contrib.postgres.fields import ArrayField from django.db import models from django.db.models.fields import NOT_PROVIDED from django.db.models.fields.json import JSONField from django.db.models.fields.related import OneToOneField +from django.utils.module_loading import import_string -from .constants import basic_field_description +from .constants import basic_field_descriptions +from .exceptions import ImproperlyConfigured @dataclass(frozen=True, slots=True) @@ -26,26 +29,34 @@ class Fields: class ConfigSettingsModel: - model: models.Model + models: list[Type[models.Model], ...] display_name: str namespace: str required_fields = tuple() all_fields = tuple() excluded_fields = ("id",) - def __init__(self): + def __init__(self, *args, **kwargs): self.config_fields = Fields() - self.create_config_fields( - require=self.required_fields, - exclude=self.excluded_fields, - include=self.all_fields, - model=self.model, - ) + for key, value in kwargs.items(): + setattr(self, key, value) + + self.update_field_descriptions() + + if not self.models: + return - @classmethod - def get_setting_name(cls, field: ConfigField) -> str: - return f"{cls.namespace}_" + field.name.upper() + for model in self.models: + self.create_config_fields( + require=self.required_fields, + exclude=self.excluded_fields, + include=self.all_fields, + model=model, + ) + + def get_setting_name(self, field: ConfigField) -> str: + return f"{self.namespace}_" + field.name.upper() @staticmethod def get_default_value(field: models.Field) -> str: @@ -77,6 +88,30 @@ def get_default_value(field: models.Field) -> str: return default + @staticmethod + def update_field_descriptions() -> None: + """ + Add custom fields + descriptions defined in settings to + `basic_field_descriptions` + """ + custom_fields = getattr(settings, "DJANGO_SETUP_CONFIG_CUSTOM_FIELDS", None) + if not custom_fields: + return + + for mapping in custom_fields: + try: + field = import_string(mapping["field"]) + except ImportError as exc: + raise ImproperlyConfigured( + "\n\nSomething went wrong when importing {field}.\n" + "Check your settings for django-setup-configuration".format( + field=mapping["field"] + ) + ) from exc + else: + description = mapping["description"] + basic_field_descriptions[field] = description + @staticmethod def get_field_description(field: models.Field) -> str: # fields with choices @@ -84,12 +119,11 @@ def get_field_description(field: models.Field) -> str: example_values = [choice[0] for choice in choices] return ", ".join(example_values) + # other fields field_type = type(field) - match field_type: - case item if item in basic_field_description.keys(): - return basic_field_description.get(item) - case _: - return "No information available" + if field_type in basic_field_descriptions.keys(): + return basic_field_descriptions.get(field_type) + return "No information available" def get_concrete_model_fields(self, model) -> Iterator[models.Field]: """ @@ -107,7 +141,7 @@ def create_config_fields( require: tuple[str, ...], exclude: tuple[str, ...], include: tuple[str, ...], - model: models.Model, + model: Type[models.Model], relating_field: models.Field | None = None, ) -> None: """ diff --git a/django_setup_configuration/configuration.py b/django_setup_configuration/configuration.py index dc8a4cf..01a5732 100644 --- a/django_setup_configuration/configuration.py +++ b/django_setup_configuration/configuration.py @@ -2,6 +2,7 @@ from django.conf import settings +from .base import ConfigSettingsModel from .exceptions import PrerequisiteFailed @@ -9,6 +10,7 @@ class BaseConfigurationStep(ABC): verbose_name: str required_settings: list[str] = [] enable_setting: str = "" + config_settings: ConfigSettingsModel def __repr__(self): return self.verbose_name @@ -20,9 +22,10 @@ def validate_requirements(self) -> None: :raises: :class: `django_setup_configuration.exceptions.PrerequisiteFailed` if prerequisites are missing """ + required_settings = self.config_settings.get_required_settings() missing = [ var - for var in self.required_settings + for var in required_settings if getattr(settings, var, None) in [None, ""] ] if missing: @@ -36,10 +39,10 @@ def is_enabled(self) -> bool: By default all steps are enabled """ - if not self.enable_setting: + if not self.config_settings.enable_setting: return True - return getattr(settings, self.enable_setting, True) + return getattr(settings, self.config_settings.enable_setting, True) @abstractmethod def is_configured(self) -> bool: diff --git a/django_setup_configuration/constants.py b/django_setup_configuration/constants.py index 13e453b..b51525b 100644 --- a/django_setup_configuration/constants.py +++ b/django_setup_configuration/constants.py @@ -1,18 +1,19 @@ from django.contrib import postgres from django.db import models -basic_field_description = { +basic_field_descriptions = { postgres.fields.ArrayField: "string, comma-delimited ('foo,bar,baz')", models.BooleanField: "True, False", models.CharField: "string", + models.EmailField: "string representing an Email address (foo@bar.com)", models.FileField: ( - "string represeting the (absolute) path to a file, " + "string representing the (absolute) path to a file, " "including file extension: {example}".format( example="/absolute/path/to/file.xml" ) ), models.ImageField: ( - "string represeting the (absolute) path to an image file, " + "string representing the (absolute) path to an image file, " "including file extension: {example}".format( example="/absolute/path/to/image.png" ) diff --git a/django_setup_configuration/management/commands/check_config_docs.py b/django_setup_configuration/management/commands/check_config_docs.py deleted file mode 100644 index fc7df10..0000000 --- a/django_setup_configuration/management/commands/check_config_docs.py +++ /dev/null @@ -1,43 +0,0 @@ -from django.conf import settings - -from ...exceptions import DocumentationCheckFailed -from .generate_config_docs import ConfigDocBaseCommand - - -class Command(ConfigDocBaseCommand): - help = "Check that changes to configuration setup classes are reflected in the docs" - - def check_doc(self, config_option: str) -> None: - SOURCE_DIR = settings.DJANGO_SETUP_CONFIG_DOC_DIR - - source_path = f"{SOURCE_DIR}/{config_option}.rst" - - try: - with open(source_path, "r") as file: - file_content = file.read() - except FileNotFoundError as exc: - msg = ( - "\nNo documentation was found for {config}\n" - "Did you forget to run generate_config_docs?\n".format( - config=self.get_config(config_option, class_name_only=True) - ) - ) - raise DocumentationCheckFailed(msg) from exc - else: - rendered_content = self.render_doc(config_option) - - if rendered_content != file_content: - raise DocumentationCheckFailed( - "Class {config} has changes which are not reflected in the " - "documentation ({source_path})." - "Did you forget to run generate_config_docs?\n".format( - config=self.get_config(config_option, class_name_only=True), - source_path=f"{SOURCE_DIR}/{config_option}.rst", - ) - ) - - def handle(self, *args, **kwargs) -> None: - supported_options = self.registry.config_model_keys - - for option in supported_options: - self.check_doc(option) diff --git a/django_setup_configuration/management/commands/generate_config_docs.py b/django_setup_configuration/management/commands/generate_config_docs.py index a1fa08a..e8d1c96 100644 --- a/django_setup_configuration/management/commands/generate_config_docs.py +++ b/django_setup_configuration/management/commands/generate_config_docs.py @@ -3,29 +3,20 @@ from django.conf import settings from django.core.management.base import BaseCommand from django.template import loader +from django.utils.module_loading import import_string from ...base import ConfigSettingsModel -from ...exceptions import ConfigurationException -from ...registry import ConfigurationRegistry -class ConfigDocBaseCommand(BaseCommand): +class ConfigDocBase: + """ + Base class encapsulating the functionality for generating + checking documentation. - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.registry = ConfigurationRegistry() + Defined independently of `BaseCommand` for more flexibility (the class could be + used without running a Django management command). + """ - def get_config( - self, config_option: str, class_name_only=False - ) -> ConfigSettingsModel: - config_model = getattr(self.registry, config_option, None) - if class_name_only: - return config_model.__name__ - - config_instance = config_model() - return config_instance - - def get_detailed_info(self, config=ConfigSettingsModel) -> list[list[str]]: + def get_detailed_info(self, config: ConfigSettingsModel) -> list[list[str]]: ret = [] for field in config.config_fields.all: part = [] @@ -44,29 +35,29 @@ def format_display_name(self, display_name): display_name_formatted = f"{heading_bar}\n{display_name}\n{heading_bar}" return display_name_formatted - def render_doc(self, config_option: str) -> None: - config = self.get_config(config_option) - + def render_doc(self, config_settings: str) -> None: required_settings = [ - config.get_setting_name(field) for field in config.config_fields.required + config_settings.get_setting_name(field) + for field in config_settings.config_fields.required ] required_settings.sort() all_settings = [ - config.get_setting_name(field) for field in config.config_fields.all + config_settings.get_setting_name(field) + for field in config_settings.config_fields.all ] all_settings.sort() - detailed_info = self.get_detailed_info(config) + detailed_info = self.get_detailed_info(config_settings) detailed_info.sort() template_variables = { - "enable_settings": f"{config.namespace}_CONFIG_ENABLE", + "enable_settings": f"{config_settings.enable_setting}", "required_settings": required_settings, "all_settings": all_settings, "detailed_info": detailed_info, - "link": f".. _{config_option}:", - "title": self.format_display_name(config.display_name), + "link": f".. _{config_settings.file_name}:", + "title": self.format_display_name(config_settings.display_name), } template = loader.get_template(settings.DJANGO_SETUP_CONFIG_TEMPLATE_NAME) @@ -75,39 +66,40 @@ def render_doc(self, config_option: str) -> None: return rendered -class Command(ConfigDocBaseCommand): - help = "Create documentation for configuration setup steps" +class Command(ConfigDocBase, BaseCommand): + help = "Generate documentation for configuration setup steps" - def add_arguments(self, parser): - parser.add_argument("config_option", nargs="?") + def content_is_up_to_date(self, rendered_content: str, doc_path: str) -> bool: + """ + Check that documentation at `doc_path` exists and that its content matches + that of `rendered_content` + """ + try: + with open(doc_path, "r") as file: + file_content = file.read() + except FileNotFoundError: + return False - def write_doc(self, config_option: str) -> None: - rendered = self.render_doc(config_option) + if not file_content == rendered_content: + return False - TARGET_DIR = settings.DJANGO_SETUP_CONFIG_DOC_DIR + return True - pathlib.Path(TARGET_DIR).mkdir(parents=True, exist_ok=True) + def handle(self, *args, **kwargs) -> str: + target_dir = settings.DJANGO_SETUP_CONFIG_DOC_DIRECTORY - output_path = f"{TARGET_DIR}/{config_option}.rst" + # create directory for docs if it doesn't exist + pathlib.Path(target_dir).mkdir(parents=True, exist_ok=True) - with open(output_path, "w+") as output: - output.write(rendered) + for config_string in settings.SETUP_CONFIGURATION_STEPS: + config_step = import_string(config_string) + config_settings = config_step.config_settings - return rendered + doc_path = f"{target_dir}/{config_settings.file_name}.rst" + rendered_content = self.render_doc(config_settings) - def handle(self, *args, **kwargs) -> None: - config_option = kwargs["config_option"] - - supported_options = self.registry.config_model_keys - - if config_option and config_option not in supported_options: - raise ConfigurationException( - f"Unsupported config option ({config_option})\n" - f"Supported: {', '.join(supported_options)}" - ) - elif config_option: - rendered = self.write_doc(config_option) - else: - for option in supported_options: - rendered = self.write_doc(option) - return rendered + if not self.content_is_up_to_date(rendered_content, doc_path): + with open(doc_path, "w+") as output: + output.write(rendered_content) + + return rendered_content diff --git a/django_setup_configuration/registry.py b/django_setup_configuration/registry.py index 0c23ad0..9c5acbc 100644 --- a/django_setup_configuration/registry.py +++ b/django_setup_configuration/registry.py @@ -6,6 +6,8 @@ class ConfigurationRegistry: + _registry = dict() + def __init__(self): if not getattr(settings, "DJANGO_SETUP_CONFIG_REGISTER", None): raise ImproperlyConfigured("DJANGO_SETUP_CONFIG_REGISTER is not defined") @@ -22,8 +24,7 @@ def __init__(self): def register_config_models(self) -> None: """ - Load config models specified in settings and set them as attributes on - the instance + Load config models specified in settings and add them to _registry """ for mapping in settings.DJANGO_SETUP_CONFIG_REGISTER: file_name = mapping.get("file_name") or mapping["model"].split(".")[-1] @@ -32,17 +33,20 @@ def register_config_models(self) -> None: model = import_string(mapping["model"]) except ImportError as exc: raise ImproperlyConfigured( - "\n\nThe class testapp.models.bogus was not found\n" + "\n\nThe class {model} was not found\n" "Check the DJANGO_SETUP_CONFIG_REGISTER setting for " - "django-setup-configuration" + "django-setup-configuration".format(model=mapping["model"]) ) from exc else: - setattr(self, file_name, model) + self._registry[file_name] = model + + def get_config_model(self, config_option: str) -> ConfigSettingsModel: + return self._registry[config_option] @property def config_models(self) -> tuple[ConfigSettingsModel, ...]: - return tuple(getattr(self, key) for key in vars(self).keys()) + return tuple(self._registry.values()) @property def config_model_keys(self) -> tuple[str, ...]: - return tuple(key for key in vars(self).keys()) + return tuple(self._registry.keys()) diff --git a/django_setup_configuration/templates/django_setup_configuration/config_doc.rst b/django_setup_configuration/templates/django_setup_configuration/config_doc.rst new file mode 100644 index 0000000..5c3ce56 --- /dev/null +++ b/django_setup_configuration/templates/django_setup_configuration/config_doc.rst @@ -0,0 +1,48 @@ +{% block link %}{{ link }}{% endblock %} + +{% block title %}{{ title }}{% endblock %} + +Settings Overview +================= + +Enable/Disable configuration: +""""""""""""""""""""""""""""" + +:: + + {% spaceless %} + {{ enable_settings }} + {% endspaceless %} + +{% if required_settings %} +Required: +""""""""" + +:: + + {% spaceless %} + {% for setting in required_settings %}{{ setting }} + {% endfor %} + {% endspaceless %} +{% endif %} + +All settings: +""""""""""""" + +:: + + {% spaceless %} + {% for setting in all_settings %}{{ setting }} + {% endfor %} + {% endspaceless %} + +Detailed Information +==================== + +:: + + {% spaceless %} + {% for detail in detailed_info %} + {% for part in detail %}{{ part|safe }} + {% endfor %}{% endfor %} + {% endspaceless %} diff --git a/setup.cfg b/setup.cfg index 8c5866b..343df37 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,4 +2,4 @@ [flake8] max-line-length=88 -exclude=env,.tox,doc +exclude=env,.tox,doc,mocks.py diff --git a/testapp/config_models.py b/testapp/config_models.py deleted file mode 100644 index 3ab418c..0000000 --- a/testapp/config_models.py +++ /dev/null @@ -1,34 +0,0 @@ -from django.contrib.postgres.fields import ArrayField -from django.db import models - - -class Service(models.Model): - url = models.URLField( - verbose_name="Service url", - help_text="The url of the service", - ) - bogus = models.CharField( - verbose_name="Bogus service field", help_text="Should not be included in docs" - ) - - -class ProductConfig(models.Model): - name = models.CharField( - verbose_name="Name", - help_text="The name of the product", - ) - service = models.OneToOneField( - to=Service, - verbose_name="Service", - default=None, - on_delete=models.SET_NULL, - help_text="API service of the product", - ) - tags = ArrayField( - base_field=models.CharField("Product tag"), - default=["example_tag"], - help_text="Tags for the product", - ) - bogus = models.CharField( - help_text="Should be excluded", - ) diff --git a/testapp/configuration.py b/testapp/configuration.py index 1a6fb4b..c026c50 100644 --- a/testapp/configuration.py +++ b/testapp/configuration.py @@ -6,16 +6,6 @@ from django_setup_configuration.configuration import BaseConfigurationStep from django_setup_configuration.exceptions import SelfTestFailed -from .config_models import ProductConfig - - -class ProductConfigurationSettings(ConfigSettingsModel): - model = ProductConfig - display_name = "Product Configuration" - namespace = "PRODUCT" - required_fields = ("name", "service_url") - all_fields = required_fields + ("tags",) - class UserConfigurationStep(BaseConfigurationStep): """ @@ -26,8 +16,15 @@ class UserConfigurationStep(BaseConfigurationStep): """ verbose_name = "User Configuration" - required_settings = ["USER_CONFIGURATION_USERNAME", "USER_CONFIGURATION_PASSWORD"] - enable_setting = "USER_CONFIGURATION_ENABLED" + config_settings = ConfigSettingsModel( + models=[User], + enable_setting="USER_CONFIGURATION_ENABLED", + display_name="User Configuration", + file_name="user", + namespace="USER_CONFIGURATION", + required_fields=("username", "password"), + excluded_fields=("id", "date_joined", "is_active", "last_login"), + ) def is_configured(self) -> bool: return User.objects.filter( diff --git a/testapp/docs/configuration/product.rst b/testapp/docs/configuration/product.rst deleted file mode 100644 index b242abf..0000000 --- a/testapp/docs/configuration/product.rst +++ /dev/null @@ -1,55 +0,0 @@ -.. _product: - -===================== -Product Configuration -===================== - -Settings Overview -================= - -Enable/Disable configuration: -""""""""""""""""""""""""""""" - -:: - - PRODUCT_CONFIG_ENABLE - -Required: -""""""""" - -:: - - PRODUCT_NAME - PRODUCT_SERVICE_URL - -All settings: -""""""""""""" - -:: - - PRODUCT_NAME - PRODUCT_SERVICE_URL - PRODUCT_TAGS - -Detailed Information -==================== - -:: - - Variable PRODUCT_NAME - Setting Name - Description The name of the product - Possible values string - Default value No default - - Variable PRODUCT_SERVICE_URL - Setting Service url - Description The url of the service - Possible values string (URL) - Default value No default - - Variable PRODUCT_TAGS - Setting tags - Description Tags for the product - Possible values string, comma-delimited ('foo,bar,baz') - Default value example_tag diff --git a/testapp/settings.py b/testapp/settings.py index dc3b19b..720b555 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -13,7 +13,7 @@ "NAME": "postgres", "USER": "postgres", "PASSWORD": "postgres", - "HOST": "localhost", + "HOST": "", } } @@ -68,11 +68,5 @@ USER_CONFIGURATION_USERNAME = os.getenv("USER_CONFIGURATION_USERNAME", "demo") USER_CONFIGURATION_PASSWORD = os.getenv("USER_CONFIGURATION_PASSWORD", "secret") -DJANGO_SETUP_CONFIG_REGISTER = [ - { - "model": "testapp.configuration.ProductConfigurationSettings", - "file_name": "product", - } -] DJANGO_SETUP_CONFIG_TEMPLATE_NAME = "testapp/config_doc.rst" -DJANGO_SETUP_CONFIG_DOC_DIR = "testapp/docs/configuration" +DJANGO_SETUP_CONFIG_DOC_DIRECTORY = "testapp/docs/configuration" diff --git a/testapp/templates/testapp/config_doc.rst b/testapp/templates/testapp/config_doc.rst index b9de9c5..e4fe009 100644 --- a/testapp/templates/testapp/config_doc.rst +++ b/testapp/templates/testapp/config_doc.rst @@ -2,11 +2,9 @@ {% block title %}{{ title }}{% endblock %} -Settings Overview -================= Enable/Disable configuration: -""""""""""""""""""""""""""""" +============================= :: @@ -14,8 +12,9 @@ Enable/Disable configuration: {{ enable_settings }} {% endspaceless %} -Required: -""""""""" +{% if required_settings %} +Required settings: +================== :: @@ -23,9 +22,10 @@ Required: {% for setting in required_settings %}{{ setting }} {% endfor %} {% endspaceless %} +{% endif %} All settings: -""""""""""""" +============= :: diff --git a/tests/mocks.py b/tests/mocks.py index 0d02d68..4c8b931 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -1 +1,3 @@ -mock_product_doc = '.. _product:\n\n=====================\nProduct Configuration\n=====================\n\nSettings Overview\n=================\n\nEnable/Disable configuration:\n"""""""""""""""""""""""""""""\n\n::\n\n PRODUCT_CONFIG_ENABLE\n\nRequired:\n"""""""""\n\n::\n\n PRODUCT_NAME\n PRODUCT_SERVICE_URL\n\nAll settings:\n"""""""""""""\n\n::\n\n PRODUCT_NAME\n PRODUCT_SERVICE_URL\n PRODUCT_TAGS\n\nDetailed Information\n====================\n\n::\n\n Variable PRODUCT_NAME\n Setting Name\n Description The name of the product\n Possible values string\n Default value No default\n \n Variable PRODUCT_SERVICE_URL\n Setting Service url\n Description The url of the service\n Possible values string (URL)\n Default value No default\n \n Variable PRODUCT_TAGS\n Setting tags\n Description Tags for the product\n Possible values string, comma-delimited (\'foo,bar,baz\')\n Default value example_tag\n' # noqa +mock_user_doc = ".. _user:\n\n==================\nUser Configuration\n==================\n\n\nEnable/Disable configuration:\n=============================\n\n::\n\n USER_CONFIGURATION_ENABLED\n\n\nRequired settings:\n==================\n\n::\n\n USER_CONFIGURATION_PASSWORD\n USER_CONFIGURATION_USERNAME\n\n\nAll settings:\n=============\n\n::\n\n USER_CONFIGURATION_EMAIL\n USER_CONFIGURATION_FIRST_NAME\n USER_CONFIGURATION_IS_STAFF\n USER_CONFIGURATION_IS_SUPERUSER\n USER_CONFIGURATION_LAST_NAME\n USER_CONFIGURATION_PASSWORD\n USER_CONFIGURATION_USERNAME\n\nDetailed Information\n====================\n\n::\n\n Variable USER_CONFIGURATION_EMAIL\n Setting email address\n Description No description\n Possible values string representing an Email address (foo@bar.com)\n Default value No default\n \n Variable USER_CONFIGURATION_FIRST_NAME\n Setting first name\n Description No description\n Possible values string\n Default value No default\n \n Variable USER_CONFIGURATION_IS_STAFF\n Setting staff status\n Description Designates whether the user can log into this admin site.\n Possible values True, False\n Default value False\n \n Variable USER_CONFIGURATION_IS_SUPERUSER\n Setting superuser status\n Description Designates that this user has all permissions without explicitly assigning them.\n Possible values True, False\n Default value False\n \n Variable USER_CONFIGURATION_LAST_NAME\n Setting last name\n Description No description\n Possible values string\n Default value No default\n \n Variable USER_CONFIGURATION_PASSWORD\n Setting password\n Description No description\n Possible values string\n Default value No default\n \n Variable USER_CONFIGURATION_USERNAME\n Setting username\n Description Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.\n Possible values string\n Default value No default\n" # noqua + +mock_user_doc_mismatch = "hello world" diff --git a/tests/test_config_docs.py b/tests/test_config_docs.py index 2cf3999..7a3b992 100644 --- a/tests/test_config_docs.py +++ b/tests/test_config_docs.py @@ -2,49 +2,48 @@ from django.core.management import call_command -import pytest +from .mocks import mock_user_doc, mock_user_doc_mismatch -from django_setup_configuration.exceptions import DocumentationCheckFailed +open_func = "django_setup_configuration.management.commands.generate_config_docs.open" -from .mocks import mock_product_doc +def test_generate_config_docs_new_file(settings): + """ + Assert that file with correct content is written if no docs exist + """ + open_mock = mock.mock_open() -def test_generate_config_docs(settings): - content = call_command("generate_config_docs") + with mock.patch(open_func, open_mock): + call_command("generate_config_docs") - assert "PRODUCT_CONFIG_ENABLE" in content - # 3 occurrences of PRODUCT_NAME: required, all settings, description - assert content.count("PRODUCT_NAME") == 3 + open_mock.assert_called_with("testapp/docs/configuration/user.rst", "w+") + open_mock.return_value.write.assert_called_once_with(mock_user_doc) - assert "PRODUCT_SERVICE_URL" in content - # 3 occurrences of PRODUCT_SERVICE_URL: required, all settings, description - assert content.count("PRODUCT_SERVICE_URL") == 3 - assert "PRODUCT_TAGS" in content - # 2 occurrences of PRODUCT_TAGS: all settings, description - assert content.count("PRODUCT_TAGS") == 2 +def test_generate_config_docs_content_mismatch(settings): + """ + Assert that file with updated content is written if the content read by `open` + is different + """ + open_mock = mock.mock_open(read_data=mock_user_doc_mismatch) - assert "PRODUCT_BOGUS" not in content - assert "PRODUCT_SERVICE_BOGUS" not in content + with mock.patch(open_func, open_mock): + call_command("generate_config_docs") + open_mock.assert_called_with("testapp/docs/configuration/user.rst", "w+") + open_mock.return_value.write.assert_called_once_with(mock_user_doc) -def test_check_config_docs_ok(settings): - with mock.patch("builtins.open", mock.mock_open(read_data=mock_product_doc)): - call_command("check_config_docs") +def test_docs_up_to_date(settings): + """ + Assert that no file is written if the content read by `open` is up to date + """ + open_mock = mock.mock_open(read_data=mock_user_doc) -def test_check_config_docs_fail_missing_docs(settings): - mock_open = mock.mock_open(read_data=mock_product_doc) - mock_open.side_effect = FileNotFoundError + with mock.patch(open_func, open_mock): + call_command("generate_config_docs") - with mock.patch("builtins.open", mock_open): - with pytest.raises(DocumentationCheckFailed): - call_command("check_config_docs") - - -@mock.patch("testapp.configuration.ProductConfigurationSettings.get_setting_name") -def test_check_config_docs_fail_content_mismatch(m, settings): - m.return_value = "" - - with pytest.raises(DocumentationCheckFailed): - call_command("check_config_docs") + assert ( + mock.call("testapp/docs/configuration/user.rst", "w+") + not in open_mock.mock_calls + ) diff --git a/tests/test_config_registry.py b/tests/test_config_registry.py deleted file mode 100644 index 6b11868..0000000 --- a/tests/test_config_registry.py +++ /dev/null @@ -1,46 +0,0 @@ -from django.core.management import call_command - -import pytest - -from django_setup_configuration.exceptions import ImproperlyConfigured -from django_setup_configuration.registry import ConfigurationRegistry -from testapp.configuration import ProductConfigurationSettings - - -def test_missing_registry(settings): - del settings.DJANGO_SETUP_CONFIG_REGISTER - - with pytest.raises(ImproperlyConfigured): - call_command("generate_config_docs") - - -def test_registry_missing_model_spec(settings): - settings.DJANGO_SETUP_CONFIG_REGISTER = [{"file_name": "test"}] - - with pytest.raises(ImproperlyConfigured): - call_command("generate_config_docs") - - -def test_registry_model_not_found(settings): - settings.DJANGO_SETUP_CONFIG_REGISTER = [{"model": "testapp.models.bogus"}] - - with pytest.raises(ImproperlyConfigured): - call_command("generate_config_docs") - - -def test_registry_property_models(): - registry = ConfigurationRegistry() - - models = registry.config_models - - assert len(models) == 1 - assert models[0] == ProductConfigurationSettings - - -def test_registry_property_model_keys(): - registry = ConfigurationRegistry() - - model_keys = registry.config_model_keys - - assert len(model_keys) == 1 - assert model_keys[0] == "product"